@memberjunction/actions-apollo 6.1.0-edge.3 → 6.1.0-edge.5

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 (56) hide show
  1. package/README.md +79 -0
  2. package/dist/__tests__/apollo-enrichment.test.js +4 -5
  3. package/dist/__tests__/apollo-enrichment.test.js.map +1 -1
  4. package/dist/__tests__/apollo-lists.test.d.ts +2 -0
  5. package/dist/__tests__/apollo-lists.test.d.ts.map +1 -0
  6. package/dist/__tests__/apollo-lists.test.js +957 -0
  7. package/dist/__tests__/apollo-lists.test.js.map +1 -0
  8. package/dist/accounts.d.ts +4 -4
  9. package/dist/accounts.d.ts.map +1 -1
  10. package/dist/accounts.js +20 -18
  11. package/dist/accounts.js.map +1 -1
  12. package/dist/config.d.ts +10 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +10 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/contacts.d.ts +4 -4
  17. package/dist/contacts.d.ts.map +1 -1
  18. package/dist/contacts.js +24 -22
  19. package/dist/contacts.js.map +1 -1
  20. package/dist/generic/apollo-lists.types.d.ts +246 -0
  21. package/dist/generic/apollo-lists.types.d.ts.map +1 -0
  22. package/dist/generic/apollo-lists.types.js +42 -0
  23. package/dist/generic/apollo-lists.types.js.map +1 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/lists/ApolloListActions.d.ts +49 -0
  29. package/dist/lists/ApolloListActions.d.ts.map +1 -0
  30. package/dist/lists/ApolloListActions.js +135 -0
  31. package/dist/lists/ApolloListActions.js.map +1 -0
  32. package/dist/lists/ApolloMoveActions.d.ts +33 -0
  33. package/dist/lists/ApolloMoveActions.d.ts.map +1 -0
  34. package/dist/lists/ApolloMoveActions.js +272 -0
  35. package/dist/lists/ApolloMoveActions.js.map +1 -0
  36. package/dist/lists/ApolloRESTBaseAction.d.ts +49 -0
  37. package/dist/lists/ApolloRESTBaseAction.d.ts.map +1 -0
  38. package/dist/lists/ApolloRESTBaseAction.js +70 -0
  39. package/dist/lists/ApolloRESTBaseAction.js.map +1 -0
  40. package/dist/lists/ApolloRESTClient.d.ts +136 -0
  41. package/dist/lists/ApolloRESTClient.d.ts.map +1 -0
  42. package/dist/lists/ApolloRESTClient.js +561 -0
  43. package/dist/lists/ApolloRESTClient.js.map +1 -0
  44. package/dist/lists/ApolloSearchActions.d.ts +64 -0
  45. package/dist/lists/ApolloSearchActions.d.ts.map +1 -0
  46. package/dist/lists/ApolloSearchActions.js +239 -0
  47. package/dist/lists/ApolloSearchActions.js.map +1 -0
  48. package/dist/lists/credentials.d.ts +57 -0
  49. package/dist/lists/credentials.d.ts.map +1 -0
  50. package/dist/lists/credentials.js +161 -0
  51. package/dist/lists/credentials.js.map +1 -0
  52. package/dist/lists/params.d.ts +61 -0
  53. package/dist/lists/params.d.ts.map +1 -0
  54. package/dist/lists/params.js +113 -0
  55. package/dist/lists/params.js.map +1 -0
  56. package/package.json +8 -7
@@ -0,0 +1,957 @@
1
+ /**
2
+ * Tests for the Apollo list, search and move surface.
3
+ *
4
+ * Two levels:
5
+ *
6
+ * - The client is driven through a fake `fetch`, so the request bodies Apollo
7
+ * would receive are asserted directly. That matters more here than usual,
8
+ * because the dangerous behaviours are all about what is IN the request: a
9
+ * move that sends the wrong label array silently deletes memberships, and
10
+ * nothing about the response would reveal it.
11
+ * - The actions are driven with a fake client, so validation, list resolution
12
+ * and result shaping are exercised without a transport.
13
+ *
14
+ * Nothing here may reach api.apollo.io. A real call would spend credits on the
15
+ * read side and mutate a real account's lists on the write side, with no undo.
16
+ */
17
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
18
+ // The only BaseAction behaviour these tests need is that Run() delegates to
19
+ // InternalRunAction; the real one drags in the whole actions engine.
20
+ vi.mock('@memberjunction/actions', () => ({
21
+ BaseAction: class BaseAction {
22
+ async Run(params) {
23
+ return this.InternalRunAction(params);
24
+ }
25
+ async InternalRunAction() {
26
+ return {};
27
+ }
28
+ },
29
+ }));
30
+ vi.mock('@memberjunction/global', () => ({
31
+ RegisterClass: () => (target) => target,
32
+ }));
33
+ /**
34
+ * `CredentialEngine` is mocked, not exercised: it extends `BaseEngine` and reaches for a provider,
35
+ * which these suites deliberately do not have. What IS asserted is that the credential path goes
36
+ * THROUGH the engine — `getCredential` is a spy, so a regression back to a raw `RunView` would show
37
+ * up as this never being called.
38
+ */
39
+ vi.mock('@memberjunction/credentials', () => {
40
+ const getCredential = vi.fn(async () => ({ values: { accessToken: 'cred-token', apiKey: 'cred-apollo-key' } }));
41
+ return {
42
+ CredentialEngine: {
43
+ Instance: {
44
+ Config: vi.fn(async () => undefined),
45
+ Credentials: [{ ID: 'cred-1', Name: 'Test Credential', IsActive: true }],
46
+ getCredential,
47
+ },
48
+ },
49
+ __getCredentialSpy: getCredential,
50
+ };
51
+ });
52
+ vi.mock('@memberjunction/core', () => ({
53
+ LogError: vi.fn(),
54
+ LogStatus: vi.fn(),
55
+ Metadata: class Metadata {
56
+ async GetEntityObject() {
57
+ throw new Error('Metadata should not be reached in these tests');
58
+ }
59
+ },
60
+ RunView: class RunView {
61
+ async RunView() {
62
+ throw new Error('RunView should not be reached in these tests');
63
+ }
64
+ },
65
+ }));
66
+ // config.ts reads APOLLO_API_KEY at module load, so it has to be set before the
67
+ // imports below rather than in a beforeEach.
68
+ process.env.APOLLO_API_KEY = 'env-key';
69
+ const { ApolloRESTClient } = await import('../lists/ApolloRESTClient.js');
70
+ const { extractApolloKey } = await import('../lists/credentials.js');
71
+ const { getParam, getParamRaw, parseOptionalBooleanParam, parseOptionalIntegerParam, parseStringArrayParam, } = await import('../lists/params.js');
72
+ const { ApolloGetListsAction, ApolloCreateListAction } = await import('../lists/ApolloListActions.js');
73
+ const { ApolloGetListAccountsAction, ApolloGetListContactsAction, ApolloSearchPeopleAction } = await import('../lists/ApolloSearchActions.js');
74
+ const { ApolloMoveListAccountsAction, ApolloMoveListContactsAction } = await import('../lists/ApolloMoveActions.js');
75
+ function fakeFetch(replies) {
76
+ const calls = [];
77
+ const fetchImpl = (async (url, init) => {
78
+ const call = {
79
+ url,
80
+ method: String(init.method),
81
+ headers: init.headers,
82
+ body: init.body ? JSON.parse(String(init.body)) : null,
83
+ };
84
+ calls.push(call);
85
+ const reply = typeof replies === 'function' ? replies(call, calls.length - 1) : replies[calls.length - 1];
86
+ if (!reply)
87
+ throw new Error(`fakeFetch: no reply configured for call ${calls.length} (${call.method} ${call.url})`);
88
+ const status = reply.status ?? 200;
89
+ return {
90
+ ok: status >= 200 && status < 300,
91
+ status,
92
+ json: async () => {
93
+ if (reply.text !== undefined)
94
+ throw new Error('Unexpected end of JSON input');
95
+ return reply.body;
96
+ },
97
+ text: async () => (reply.text !== undefined ? reply.text : JSON.stringify(reply.body)),
98
+ };
99
+ });
100
+ return { fetchImpl, calls };
101
+ }
102
+ const LABELS_BODY = {
103
+ labels: [
104
+ { id: 'lab-cold', name: 'Cold', modality: 'accounts', cached_count: 40, created_at: 'c1', updated_at: 'u1' },
105
+ { id: 'lab-warm', name: 'Warm', modality: 'accounts', cached_count: 3, created_at: 'c2', updated_at: 'u2' },
106
+ { id: 'lab-keep', name: 'Do Not Touch', modality: 'accounts', cached_count: 9, created_at: 'c3', updated_at: 'u3' },
107
+ { id: 'lab-people', name: 'Qualified People', modality: 'contacts', cached_count: 12, created_at: 'c4', updated_at: 'u4' },
108
+ ],
109
+ };
110
+ function client(replies) {
111
+ const { fetchImpl, calls } = fakeFetch(replies);
112
+ return { c: new ApolloRESTClient('key-1', { fetchImpl }), calls };
113
+ }
114
+ // ── Client: transport ─────────────────────────────────────────────────────────
115
+ describe('ApolloRESTClient transport', () => {
116
+ it('sends the key as X-Api-Key against the /api/v1 base path', async () => {
117
+ const { c, calls } = client([{ body: LABELS_BODY }]);
118
+ await c.listLabels();
119
+ expect(calls[0].url).toBe('https://api.apollo.io/api/v1/labels');
120
+ expect(calls[0].headers['X-Api-Key']).toBe('key-1');
121
+ });
122
+ it('refuses to construct without a key', () => {
123
+ expect(() => new ApolloRESTClient('')).toThrow(/apiKey is required/);
124
+ expect(() => new ApolloRESTClient(' ')).toThrow(/apiKey is required/);
125
+ });
126
+ it('explains that a 403 on a label read means a scoped key, not a wrong one', async () => {
127
+ const { c } = client([{ status: 403, body: { error: 'Need master api key' } }]);
128
+ await expect(c.listLabels()).rejects.toThrow(/requires a MASTER Apollo API key/);
129
+ });
130
+ it('reports a plain failure with its status and Apollo detail', async () => {
131
+ const { c } = client([{ status: 422, body: { error: 'Please enter a non-empty modality!' } }]);
132
+ await expect(c.createLabel('X')).rejects.toThrow(/HTTP 422 — Please enter a non-empty modality!/);
133
+ });
134
+ it('surfaces a body that is not JSON rather than reporting an empty response', async () => {
135
+ const { c } = client([{ text: '<html>gateway timeout</html>' }]);
136
+ await expect(c.listLabels()).rejects.toThrow(/non-JSON body/);
137
+ });
138
+ it('keeps the raw body text when an error response is not JSON either', async () => {
139
+ const { c } = client([{ status: 502, text: 'upstream connect error' }]);
140
+ await expect(c.listLabels()).rejects.toThrow(/HTTP 502 — upstream connect error/);
141
+ });
142
+ });
143
+ // ── Client: labels ────────────────────────────────────────────────────────────
144
+ describe('ApolloRESTClient labels', () => {
145
+ it('reads the enveloped and the bare array shape alike', async () => {
146
+ const enveloped = client([{ body: LABELS_BODY }]);
147
+ expect((await enveloped.c.listLabels())).toHaveLength(4);
148
+ const bare = client([{ body: LABELS_BODY.labels }]);
149
+ expect((await bare.c.listLabels())).toHaveLength(4);
150
+ });
151
+ it('surfaces Apollo modality as kind', async () => {
152
+ const { c } = client([{ body: LABELS_BODY }]);
153
+ const labels = await c.listLabels();
154
+ expect(labels[0]).toEqual({
155
+ id: 'lab-cold',
156
+ name: 'Cold',
157
+ kind: 'accounts',
158
+ cachedCount: 40,
159
+ createdAt: 'c1',
160
+ updatedAt: 'u1',
161
+ });
162
+ });
163
+ it('rejects a response that is neither an array nor an envelope', async () => {
164
+ const { c } = client([{ body: { data: 'nope' } }]);
165
+ await expect(c.listLabels()).rejects.toThrow(/unexpected response shape/);
166
+ });
167
+ it('sends the modality Apollo requires on create', async () => {
168
+ const { c, calls } = client([{ body: { id: 'lab-new', name: 'Fresh', modality: 'contacts' } }]);
169
+ await c.createLabel('Fresh');
170
+ expect(calls[0].method).toBe('POST');
171
+ expect(calls[0].body).toEqual({ name: 'Fresh', modality: 'contacts' });
172
+ });
173
+ it('creates an accounts label when asked', async () => {
174
+ const { c, calls } = client([{ body: { id: 'lab-new', name: 'Fresh', modality: 'accounts' } }]);
175
+ const created = await c.createLabel('Fresh', 'accounts');
176
+ expect(calls[0].body?.modality).toBe('accounts');
177
+ expect(created.kind).toBe('accounts');
178
+ });
179
+ it('accepts every create response shape Apollo has been seen to use', async () => {
180
+ for (const body of [
181
+ { id: 'lab-new', name: 'Fresh' },
182
+ { label: { id: 'lab-new', name: 'Fresh' } },
183
+ { labels: [{ id: 'lab-new', name: 'Fresh' }] },
184
+ ]) {
185
+ const { c } = client([{ body }]);
186
+ expect((await c.createLabel('Fresh')).id).toBe('lab-new');
187
+ }
188
+ });
189
+ it('refuses a create response with no id rather than returning a label that does not exist', async () => {
190
+ // An empty id would be written into a later label_names array as ''.
191
+ const { c } = client([{ body: { name: 'Fresh' } }]);
192
+ await expect(c.createLabel('Fresh')).rejects.toThrow(/carried no label id/);
193
+ });
194
+ it('requires a name to create or to look up', async () => {
195
+ const { c } = client([]);
196
+ await expect(c.createLabel(' ')).rejects.toThrow(/name is required/);
197
+ await expect(c.findLabelByName(' ')).rejects.toThrow(/name is required/);
198
+ });
199
+ it('matches a label name case-insensitively and returns null for no match', async () => {
200
+ const found = client([{ body: LABELS_BODY }]);
201
+ expect((await found.c.findLabelByName(' cOLD '))?.id).toBe('lab-cold');
202
+ const missing = client([{ body: LABELS_BODY }]);
203
+ expect(await missing.c.findLabelByName('Nonexistent')).toBeNull();
204
+ });
205
+ });
206
+ // ── Client: searches ──────────────────────────────────────────────────────────
207
+ describe('ApolloRESTClient searches', () => {
208
+ const accountsBody = {
209
+ accounts: [
210
+ { id: 'acc-1', name: 'Acme', primary_domain: 'acme.com', label_ids: ['lab-cold', 'lab-keep'] },
211
+ { id: 'acc-2', name: 'Globex', primary_domain: 'globex.com', label_ids: ['lab-cold'] },
212
+ ],
213
+ pagination: { page: 1, per_page: 100, total_entries: 2, total_pages: 1 },
214
+ };
215
+ it('resolves the label ids the search returns into the names a move needs', async () => {
216
+ // The search payload carries no label_names at all — without this resolution
217
+ // the move math would have nothing to preserve.
218
+ const { c } = client([{ body: LABELS_BODY }, { body: accountsBody }]);
219
+ const page = await c.searchAccounts({ labelIds: ['lab-cold'] });
220
+ expect(page.accounts[0].labelIds).toEqual(['lab-cold', 'lab-keep']);
221
+ expect(page.accounts[0].labelNames).toEqual(['Cold', 'Do Not Touch']);
222
+ });
223
+ it('drops a label id with no matching label instead of passing it through as a name', async () => {
224
+ // A raw id inside a later label_names write would create a label named after a hex string.
225
+ const body = {
226
+ accounts: [{ id: 'acc-1', name: 'Acme', label_ids: ['lab-cold', 'lab-deleted'] }],
227
+ pagination: {},
228
+ };
229
+ const { c } = client([{ body: LABELS_BODY }, { body }]);
230
+ const page = await c.searchAccounts({});
231
+ expect(page.accounts[0].labelNames).toEqual(['Cold']);
232
+ expect(page.accounts[0].labelIds).toContain('lab-deleted');
233
+ });
234
+ it('fetches the labels once and reuses them across searches', async () => {
235
+ const { c, calls } = client([{ body: LABELS_BODY }, { body: accountsBody }, { body: accountsBody }]);
236
+ await c.searchAccounts({});
237
+ await c.searchAccounts({});
238
+ expect(calls.filter((call) => call.url.endsWith('/labels'))).toHaveLength(1);
239
+ });
240
+ it('maps the account filter onto Apollo field names', async () => {
241
+ const { c, calls } = client([{ body: LABELS_BODY }, { body: accountsBody }]);
242
+ await c.searchAccounts({ labelIds: ['lab-cold'], keywords: 'assoc' });
243
+ expect(calls[1].url).toBe('https://api.apollo.io/api/v1/accounts/search');
244
+ expect(calls[1].body).toEqual({
245
+ page: 1,
246
+ per_page: 100,
247
+ account_label_ids: ['lab-cold'],
248
+ q_organization_name: 'assoc',
249
+ });
250
+ });
251
+ it('maps the contact filter onto its own differently-named keyword field', async () => {
252
+ const body = { contacts: [], pagination: {} };
253
+ const { c, calls } = client([{ body: LABELS_BODY }, { body }]);
254
+ await c.searchContacts({ labelIds: ['lab-people'], keywords: 'director' });
255
+ expect(calls[1].body).toEqual({
256
+ page: 1,
257
+ per_page: 100,
258
+ contact_label_ids: ['lab-people'],
259
+ q_keywords: 'director',
260
+ });
261
+ });
262
+ it('omits filters that were not supplied rather than sending empty arrays', async () => {
263
+ const { c, calls } = client([{ body: LABELS_BODY }, { body: accountsBody }]);
264
+ await c.searchAccounts({ labelIds: [], keywords: '' });
265
+ expect(calls[1].body).toEqual({ page: 1, per_page: 100 });
266
+ });
267
+ it('defaults to page 1, which is what makes a drain correct', async () => {
268
+ const { c, calls } = client([{ body: LABELS_BODY }, { body: accountsBody }]);
269
+ await c.searchAccounts({});
270
+ expect(calls[1].body?.page).toBe(1);
271
+ });
272
+ it("clamps paging into Apollo's bounds", async () => {
273
+ const { c, calls } = client([{ body: LABELS_BODY }, { body: accountsBody }, { body: accountsBody }]);
274
+ await c.searchAccounts({}, { page: 9999, perPage: 9999 });
275
+ expect(calls[1].body).toMatchObject({ page: 500, per_page: 100 });
276
+ await c.searchAccounts({}, { page: 0, perPage: 0 });
277
+ expect(calls[2].body).toMatchObject({ page: 1, per_page: 1 });
278
+ });
279
+ it('reads the pagination envelope', async () => {
280
+ const { c } = client([{ body: LABELS_BODY }, { body: accountsBody }]);
281
+ const page = await c.searchAccounts({});
282
+ expect(page.pagination).toEqual({ page: 1, perPage: 100, totalEntries: 2, totalPages: 1 });
283
+ });
284
+ it('defaults a missing pagination envelope rather than failing the page', async () => {
285
+ const { c } = client([{ body: LABELS_BODY }, { body: { accounts: [] } }]);
286
+ expect((await c.searchAccounts({})).pagination).toEqual({ page: 1, perPage: 0, totalEntries: 0, totalPages: 0 });
287
+ });
288
+ it('accepts accounts under the organizations key too', async () => {
289
+ const body = { organizations: [{ id: 'acc-9', name: 'Initech' }], pagination: {} };
290
+ const { c } = client([{ body: LABELS_BODY }, { body }]);
291
+ expect((await c.searchAccounts({})).accounts[0].id).toBe('acc-9');
292
+ });
293
+ it('reads a contact employer from either the flat or the nested field', async () => {
294
+ const body = {
295
+ contacts: [
296
+ { id: 'con-1', first_name: 'A', last_name: 'B', organization_name: 'Flat Co' },
297
+ { id: 'con-2', first_name: 'C', last_name: 'D', organization: { name: 'Nested Co' } },
298
+ ],
299
+ pagination: {},
300
+ };
301
+ const { c } = client([{ body: LABELS_BODY }, { body }]);
302
+ const page = await c.searchContacts({});
303
+ expect(page.contacts.map((x) => x.organizationName)).toEqual(['Flat Co', 'Nested Co']);
304
+ });
305
+ it('maps the people filter onto its four Apollo fields', async () => {
306
+ const body = { people: [], pagination: {} };
307
+ const { c, calls } = client([{ body }]);
308
+ await c.searchPeople({
309
+ organizationDomains: ['acme.com'],
310
+ organizationIds: ['org-1'],
311
+ titles: ['VP of Marketing'],
312
+ seniorities: ['vp'],
313
+ });
314
+ expect(calls[0].url).toBe('https://api.apollo.io/api/v1/mixed_people/api_search');
315
+ expect(calls[0].body).toEqual({
316
+ page: 1,
317
+ per_page: 100,
318
+ q_organization_domains_list: ['acme.com'],
319
+ organization_ids: ['org-1'],
320
+ person_titles: ['VP of Marketing'],
321
+ person_seniorities: ['vp'],
322
+ });
323
+ });
324
+ it('does not spend a master-key labels call on a prospecting search', async () => {
325
+ // The prospecting response carries no memberships, so there is nothing to resolve.
326
+ const { c, calls } = client([{ body: { people: [], pagination: {} } }]);
327
+ await c.searchPeople({ titles: ['CEO'] });
328
+ expect(calls).toHaveLength(1);
329
+ expect(calls[0].url).not.toContain('/labels');
330
+ });
331
+ it('accepts people under the legacy contacts key', async () => {
332
+ const body = { contacts: [{ id: 'p-1', first_name: 'E', organization: { id: 'org-2', name: 'Umbrella' } }], pagination: {} };
333
+ const { c } = client([{ body }]);
334
+ const page = await c.searchPeople({ titles: ['CEO'] });
335
+ expect(page.people[0].organizationId).toBe('org-2');
336
+ expect(page.people[0].organizationName).toBe('Umbrella');
337
+ });
338
+ it('reports a null organization id rather than an empty string when absent', async () => {
339
+ const { c } = client([{ body: { people: [{ id: 'p-1' }], pagination: {} } }]);
340
+ expect((await c.searchPeople({ titles: ['CEO'] })).people[0].organizationId).toBeNull();
341
+ });
342
+ it('rejects a search response that is not an object', async () => {
343
+ const { c } = client([{ body: LABELS_BODY }, { body: [] }]);
344
+ await expect(c.searchAccounts({})).rejects.toThrow(/unexpected response shape/);
345
+ });
346
+ });
347
+ // ── Client: moves ─────────────────────────────────────────────────────────────
348
+ describe('ApolloRESTClient moves', () => {
349
+ const acme = {
350
+ id: 'acc-1',
351
+ name: 'Acme',
352
+ primaryDomain: 'acme.com',
353
+ labelIds: ['lab-cold', 'lab-keep'],
354
+ labelNames: ['Cold', 'Do Not Touch'],
355
+ };
356
+ /**
357
+ * Replies for a move with verify: labels (for the destination id) → PATCH →
358
+ * PATCH → labels + destination-list search.
359
+ *
360
+ * `verifyLabels` is the record's label_ids as the verify read finds them, or
361
+ * `null` for "the record is not on the destination page at all" — a genuinely
362
+ * different case from "on the page with no labels".
363
+ */
364
+ function moveReplies(verifyLabels) {
365
+ return (call) => {
366
+ if (call.url.endsWith('/labels'))
367
+ return { body: LABELS_BODY };
368
+ if (call.method === 'PATCH')
369
+ return { body: { account: { id: 'acc-1' } } };
370
+ return {
371
+ body: {
372
+ accounts: verifyLabels === null ? [] : [{ id: 'acc-1', name: 'Acme', label_ids: verifyLabels }],
373
+ pagination: {},
374
+ },
375
+ };
376
+ };
377
+ }
378
+ it('preserves every other membership on both writes', async () => {
379
+ // This is the whole point of the two-step: Apollo replaces the array, so a
380
+ // bare ['Warm'] would silently drop 'Do Not Touch'.
381
+ const { c, calls } = client(moveReplies(['lab-warm', 'lab-keep']));
382
+ await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
383
+ const patches = calls.filter((x) => x.method === 'PATCH');
384
+ expect(patches).toHaveLength(2);
385
+ expect(patches[0].body).toEqual({ label_names: ['Cold', 'Do Not Touch', 'Warm'] });
386
+ expect(patches[1].body).toEqual({ label_names: ['Do Not Touch', 'Warm'] });
387
+ });
388
+ it('keeps the destination label on the remove write', async () => {
389
+ // Computing the remove set from the pre-add state would add then immediately un-add.
390
+ const { c, calls } = client(moveReplies(['lab-warm', 'lab-keep']));
391
+ await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
392
+ expect(calls.filter((x) => x.method === 'PATCH')[1].body?.label_names).toContain('Warm');
393
+ });
394
+ it('patches the record by id on the account path', async () => {
395
+ const { c, calls } = client(moveReplies(['lab-warm']));
396
+ await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
397
+ expect(calls.find((x) => x.method === 'PATCH')?.url).toBe('https://api.apollo.io/api/v1/accounts/acc-1');
398
+ });
399
+ it('flags a record whose source label survived the remove', async () => {
400
+ const { c } = client(moveReplies(['lab-cold', 'lab-warm']));
401
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
402
+ expect(result.possiblyStuckCount).toBe(1);
403
+ expect(result.movedCount).toBe(0);
404
+ expect(result.items[0]).toMatchObject({ added: true, removed: true, possiblyStuck: true, error: null });
405
+ });
406
+ it('does not retry a stuck record', async () => {
407
+ // An immediate retry flakes the same way; the next drain pass cleans it up.
408
+ const { c, calls } = client(moveReplies(['lab-cold', 'lab-warm']));
409
+ await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
410
+ expect(calls.filter((x) => x.method === 'PATCH')).toHaveLength(2);
411
+ });
412
+ it('counts a clean move once the verify read confirms the source is gone', async () => {
413
+ const { c } = client(moveReplies(['lab-warm', 'lab-keep']));
414
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
415
+ expect(result).toMatchObject({ movedCount: 1, failedCount: 0, possiblyStuckCount: 0 });
416
+ expect(result.items[0].possiblyStuck).toBe(false);
417
+ });
418
+ it('leaves the stuck state unknown when the record is not on the destination page', async () => {
419
+ // Absent from page 1 is not evidence the remove worked, so it must not be
420
+ // reported as verified-clean.
421
+ const { c } = client(moveReplies(null));
422
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
423
+ expect(result.items[0].possiblyStuck).toBeNull();
424
+ expect(result.movedCount).toBe(1);
425
+ });
426
+ it('reports not-stuck only when it actually read the record back', async () => {
427
+ // On the page, source label gone — the one case that is genuinely verified.
428
+ const { c } = client(moveReplies(['lab-warm']));
429
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
430
+ expect(result.items[0].possiblyStuck).toBe(false);
431
+ });
432
+ it('skips the verify read, and the label lookup it needs, when not asked to verify', async () => {
433
+ const { c, calls } = client((call) => (call.method === 'PATCH' ? { body: {} } : { body: LABELS_BODY }));
434
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm');
435
+ expect(calls.filter((x) => x.method !== 'PATCH')).toHaveLength(0);
436
+ expect(result.items[0].possiblyStuck).toBeNull();
437
+ expect(result.movedCount).toBe(1);
438
+ });
439
+ it('never removes the source label when the add failed', async () => {
440
+ // Otherwise the record leaves the source list without reaching the destination.
441
+ let patches = 0;
442
+ const { c, calls } = client((call) => {
443
+ if (call.url.endsWith('/labels'))
444
+ return { body: LABELS_BODY };
445
+ patches++;
446
+ return { status: 500, body: { error: 'boom' } };
447
+ });
448
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
449
+ expect(patches).toBe(1);
450
+ expect(calls.filter((x) => x.method === 'PATCH')).toHaveLength(1);
451
+ expect(result).toMatchObject({ failedCount: 1, movedCount: 0 });
452
+ expect(result.items[0]).toMatchObject({ added: false, removed: false });
453
+ expect(result.items[0].error).toMatch(/^add phase:/);
454
+ });
455
+ it('reports a failed remove distinctly, since the record is now in both lists', async () => {
456
+ let seen = 0;
457
+ const { c } = client((call) => {
458
+ if (call.url.endsWith('/labels'))
459
+ return { body: LABELS_BODY };
460
+ seen++;
461
+ return seen === 1 ? { body: {} } : { status: 500, body: { error: 'boom' } };
462
+ });
463
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
464
+ expect(result.items[0]).toMatchObject({ added: true, removed: false });
465
+ expect(result.items[0].error).toMatch(/^remove phase:/);
466
+ expect(result.failedCount).toBe(1);
467
+ });
468
+ it('does not fail a move because the verify read failed', async () => {
469
+ let seen = 0;
470
+ const { c } = client((call) => {
471
+ if (call.url.endsWith('/labels'))
472
+ return { body: LABELS_BODY };
473
+ if (call.method === 'PATCH')
474
+ return { body: {} };
475
+ seen++;
476
+ return { status: 500, body: { error: 'search down' } };
477
+ });
478
+ const result = await c.moveAccounts([acme], 'Cold', 'Warm', { verify: true });
479
+ expect(seen).toBe(1);
480
+ expect(result).toMatchObject({ movedCount: 1, failedCount: 0 });
481
+ expect(result.items[0].possiblyStuck).toBeNull();
482
+ });
483
+ it('adds the destination idempotently when the record already carries it', async () => {
484
+ const both = { ...acme, labelNames: ['Cold', 'Warm'] };
485
+ const { c, calls } = client(moveReplies(['lab-warm']));
486
+ await c.moveAccounts([both], 'Cold', 'Warm', { verify: true });
487
+ const patches = calls.filter((x) => x.method === 'PATCH');
488
+ expect(patches[0].body).toEqual({ label_names: ['Cold', 'Warm'] });
489
+ expect(patches[1].body).toEqual({ label_names: ['Warm'] });
490
+ });
491
+ it('refuses a move to the same list, and a move with a blank list name', async () => {
492
+ const { c } = client([]);
493
+ await expect(c.moveAccounts([acme], 'Cold', 'Cold')).rejects.toThrow(/identical/);
494
+ await expect(c.moveAccounts([acme], ' ', 'Warm')).rejects.toThrow(/both required/);
495
+ });
496
+ it('returns an empty result for an empty batch without touching Apollo', async () => {
497
+ const { c, calls } = client([]);
498
+ const result = await c.moveAccounts([], 'Cold', 'Warm');
499
+ expect(result).toEqual({ movedCount: 0, failedCount: 0, possiblyStuckCount: 0, items: [] });
500
+ expect(calls).toHaveLength(0);
501
+ });
502
+ it('patches contacts on the contact path with the same label math', async () => {
503
+ const contact = {
504
+ id: 'con-1',
505
+ firstName: 'A',
506
+ lastName: 'B',
507
+ name: 'A B',
508
+ title: 'VP',
509
+ email: 'a@b.com',
510
+ organizationName: 'Acme',
511
+ labelIds: ['lab-people'],
512
+ labelNames: ['Qualified People'],
513
+ };
514
+ const { c, calls } = client((call) => {
515
+ if (call.url.endsWith('/labels'))
516
+ return { body: LABELS_BODY };
517
+ if (call.method === 'PATCH')
518
+ return { body: {} };
519
+ return { body: { contacts: [{ id: 'con-1', label_ids: ['lab-warm'] }], pagination: {} } };
520
+ });
521
+ const result = await c.moveContacts([contact], 'Qualified People', 'Warm', { verify: true });
522
+ const patches = calls.filter((x) => x.method === 'PATCH');
523
+ expect(patches[0].url).toBe('https://api.apollo.io/api/v1/contacts/con-1');
524
+ expect(patches[0].body).toEqual({ label_names: ['Qualified People', 'Warm'] });
525
+ expect(patches[1].body).toEqual({ label_names: ['Warm'] });
526
+ expect(result.movedCount).toBe(1);
527
+ });
528
+ it('processes a batch in order, and one failed record does not stop the rest', async () => {
529
+ const globex = { ...acme, id: 'acc-2', labelNames: ['Cold'] };
530
+ const { c } = client((call) => {
531
+ if (call.url.endsWith('/labels'))
532
+ return { body: LABELS_BODY };
533
+ if (call.method === 'PATCH') {
534
+ return call.url.endsWith('acc-1') ? { status: 500, body: { error: 'boom' } } : { body: {} };
535
+ }
536
+ return { body: { accounts: [{ id: 'acc-2', label_ids: ['lab-warm'] }], pagination: {} } };
537
+ });
538
+ const result = await c.moveAccounts([acme, globex], 'Cold', 'Warm', { verify: true });
539
+ expect(result).toMatchObject({ failedCount: 1, movedCount: 1 });
540
+ expect(result.items.map((i) => i.id)).toEqual(['acc-1', 'acc-2']);
541
+ });
542
+ });
543
+ // ── Params ────────────────────────────────────────────────────────────────────
544
+ describe('param parsing', () => {
545
+ const withParams = (list) => ({ Params: list });
546
+ it('finds a param case-insensitively and trims it', () => {
547
+ expect(getParam(withParams([{ Name: 'listname', Value: ' Cold ' }]), 'ListName')).toBe('Cold');
548
+ });
549
+ it('treats whitespace as absent, so a required check catches a blank list name', () => {
550
+ expect(getParam(withParams([{ Name: 'ListName', Value: ' ' }]), 'ListName')).toBeNull();
551
+ expect(getParam(withParams([]), 'ListName')).toBeNull();
552
+ });
553
+ it('returns a raw value untouched, for the array/JSON/CSV parsers', () => {
554
+ expect(getParamRaw(withParams([{ Name: 'Titles', Value: ['CEO'] }]), 'Titles')).toEqual(['CEO']);
555
+ });
556
+ it('accepts a list as an array, a JSON string, or a comma-separated string', () => {
557
+ expect(parseStringArrayParam(['CEO', 'CTO'], 'Titles').value).toEqual(['CEO', 'CTO']);
558
+ expect(parseStringArrayParam('["CEO","CTO"]', 'Titles').value).toEqual(['CEO', 'CTO']);
559
+ expect(parseStringArrayParam(' owner , founder ', 'Seniorities').value).toEqual(['owner', 'founder']);
560
+ });
561
+ it('treats an absent or empty list as unsupplied rather than as an empty filter', () => {
562
+ for (const raw of [null, undefined, '', ' , ', []]) {
563
+ expect(parseStringArrayParam(raw, 'Titles')).toEqual({ value: undefined, error: null });
564
+ }
565
+ });
566
+ it('reports a malformed JSON array instead of silently dropping the filter', () => {
567
+ expect(parseStringArrayParam('["CEO"', 'Titles').error).toMatch(/unparseable/);
568
+ expect(parseStringArrayParam([1, 2], 'Titles').error).toMatch(/JSON array of strings/);
569
+ });
570
+ it('parses an integer from a number or a numeric string, and enforces the range', () => {
571
+ expect(parseOptionalIntegerParam(5, 'Page', { min: 1, max: 500 }).value).toBe(5);
572
+ expect(parseOptionalIntegerParam(' 5 ', 'Page').value).toBe(5);
573
+ expect(parseOptionalIntegerParam(0, 'Page', { min: 1 }).error).toMatch(/>= 1/);
574
+ expect(parseOptionalIntegerParam(501, 'Page', { max: 500 }).error).toMatch(/<= 500/);
575
+ });
576
+ it('rejects a fractional page rather than rounding it into a plausible wrong answer', () => {
577
+ expect(parseOptionalIntegerParam(1.5, 'Page').error).toMatch(/must be an integer/);
578
+ expect(parseOptionalIntegerParam('abc', 'Page').error).toMatch(/must be an integer/);
579
+ });
580
+ it("does not treat the string 'false' as true", () => {
581
+ expect(parseOptionalBooleanParam('false', 'Verify').value).toBe(false);
582
+ expect(parseOptionalBooleanParam('TRUE', 'Verify').value).toBe(true);
583
+ expect(parseOptionalBooleanParam(false, 'Verify').value).toBe(false);
584
+ expect(parseOptionalBooleanParam('yes', 'Verify').error).toMatch(/must be a boolean/);
585
+ });
586
+ });
587
+ // ── Credentials ───────────────────────────────────────────────────────────────
588
+ describe('credential parsing', () => {
589
+ it('accepts the key under any of the casings a hand-authored credential uses', () => {
590
+ for (const key of ['apiKey', 'APIKey', 'api_key', 'ApiKey', 'masterApiKey']) {
591
+ expect(extractApolloKey(JSON.stringify({ [key]: 'k-1' }), 'Apollo')).toBe('k-1');
592
+ }
593
+ });
594
+ it('trims the key and treats a blank one as absent', () => {
595
+ expect(extractApolloKey(JSON.stringify({ apiKey: ' k-1 ' }), 'Apollo')).toBe('k-1');
596
+ expect(extractApolloKey(JSON.stringify({ apiKey: ' ' }), 'Apollo')).toBeNull();
597
+ expect(extractApolloKey('', 'Apollo')).toBeNull();
598
+ expect(extractApolloKey(null, 'Apollo')).toBeNull();
599
+ });
600
+ it('throws on a broken Values payload rather than falling through to another workspace key', () => {
601
+ expect(() => extractApolloKey('{not json', 'Apollo')).toThrow(/not valid JSON/);
602
+ expect(() => extractApolloKey('"a string"', 'Apollo')).toThrow(/must be a JSON object/);
603
+ });
604
+ it('reports no key when the payload holds something unrelated', () => {
605
+ expect(extractApolloKey(JSON.stringify({ token: 'k-1' }), 'Apollo')).toBeNull();
606
+ });
607
+ });
608
+ // ── Actions ───────────────────────────────────────────────────────────────────
609
+ /** A fake client whose every method is a spy; unimplemented ones throw. */
610
+ function fakeClient(overrides) {
611
+ const notImplemented = (name) => async () => {
612
+ throw new Error(`fakeClient.${name} was not expected to be called`);
613
+ };
614
+ return {
615
+ listLabels: notImplemented('listLabels'),
616
+ createLabel: notImplemented('createLabel'),
617
+ findLabelByName: notImplemented('findLabelByName'),
618
+ searchAccounts: notImplemented('searchAccounts'),
619
+ searchContacts: notImplemented('searchContacts'),
620
+ searchPeople: notImplemented('searchPeople'),
621
+ moveAccounts: notImplemented('moveAccounts'),
622
+ moveContacts: notImplemented('moveContacts'),
623
+ ...overrides,
624
+ };
625
+ }
626
+ const LABELS = [
627
+ { id: 'lab-cold', name: 'Cold', kind: 'accounts', cachedCount: 40, createdAt: '', updatedAt: '' },
628
+ { id: 'lab-warm', name: 'Warm', kind: 'accounts', cachedCount: 3, createdAt: '', updatedAt: '' },
629
+ ];
630
+ /**
631
+ * Run an action against a fake client, with `APOLLO_API_KEY` set so credential
632
+ * resolution takes the environment path and never touches the database.
633
+ */
634
+ async function run(ActionClass, inputs, client) {
635
+ const params = {
636
+ Params: Object.entries(inputs).map(([Name, Value]) => ({ Name, Value, Type: 'Input' })),
637
+ ContextUser: {},
638
+ };
639
+ const action = new ActionClass();
640
+ // The only seam: substitute the client so no transport is constructed.
641
+ action.CreateClient = () => client;
642
+ const result = await action.Run(params);
643
+ return { result, params };
644
+ }
645
+ /** Read an output param the action pushed. */
646
+ function output(params, name) {
647
+ return params.Params.find((p) => p.Name === name)?.Value;
648
+ }
649
+ beforeEach(() => {
650
+ process.env.APOLLO_API_KEY = 'env-key';
651
+ vi.resetModules();
652
+ });
653
+ describe('ApolloGetListsAction', () => {
654
+ it('returns the labels and their count', async () => {
655
+ const { result, params } = await run(ApolloGetListsAction, {}, fakeClient({ listLabels: async () => LABELS }));
656
+ expect(result.Success).toBe(true);
657
+ expect(result.ResultCode).toBe('SUCCESS');
658
+ expect(output(params, 'Count')).toBe(2);
659
+ expect(output(params, 'Lists')).toEqual(LABELS);
660
+ });
661
+ it('turns a client failure into a result rather than throwing', async () => {
662
+ const { result } = await run(ApolloGetListsAction, {}, fakeClient({
663
+ listLabels: async () => {
664
+ throw new Error('requires a MASTER Apollo API key');
665
+ },
666
+ }));
667
+ expect(result.Success).toBe(false);
668
+ expect(result.ResultCode).toBe('ERROR');
669
+ expect(result.Message).toMatch(/MASTER Apollo API key/);
670
+ });
671
+ });
672
+ describe('ApolloCreateListAction', () => {
673
+ it('requires a list name', async () => {
674
+ const { result } = await run(ApolloCreateListAction, {}, fakeClient({}));
675
+ expect(result.ResultCode).toBe('MISSING_REQUIRED_FIELDS');
676
+ });
677
+ it('returns an existing same-named list instead of creating a duplicate', async () => {
678
+ // Two labels with one name would make every name-based lookup ambiguous.
679
+ const createLabel = vi.fn();
680
+ const { result, params } = await run(ApolloCreateListAction, { ListName: ' cold ' }, fakeClient({ listLabels: async () => LABELS, createLabel: createLabel }));
681
+ expect(result.Success).toBe(true);
682
+ expect(output(params, 'AlreadyExisted')).toBe(true);
683
+ expect(output(params, 'ListID')).toBe('lab-cold');
684
+ expect(createLabel).not.toHaveBeenCalled();
685
+ });
686
+ it('creates the list when no label matches', async () => {
687
+ const created = { id: 'lab-new', name: 'Fresh', kind: 'contacts', cachedCount: 0, createdAt: '', updatedAt: '' };
688
+ const createLabel = vi.fn(async () => created);
689
+ const { result, params } = await run(ApolloCreateListAction, { ListName: 'Fresh' }, fakeClient({ listLabels: async () => LABELS, createLabel }));
690
+ expect(result.Success).toBe(true);
691
+ expect(output(params, 'AlreadyExisted')).toBe(false);
692
+ expect(createLabel).toHaveBeenCalledWith('Fresh', 'contacts');
693
+ });
694
+ it('passes the requested modality through, and rejects any other value', async () => {
695
+ const createLabel = vi.fn(async () => LABELS[0]);
696
+ await run(ApolloCreateListAction, { ListName: 'Fresh', Modality: 'Accounts' }, fakeClient({ listLabels: async () => [], createLabel }));
697
+ expect(createLabel).toHaveBeenCalledWith('Fresh', 'accounts');
698
+ const { result } = await run(ApolloCreateListAction, { ListName: 'Fresh', Modality: 'people' }, fakeClient({}));
699
+ expect(result.ResultCode).toBe('VALIDATION_ERROR');
700
+ });
701
+ });
702
+ describe('ApolloGetListAccountsAction', () => {
703
+ const page = {
704
+ accounts: [{ id: 'acc-1', name: 'Acme', primaryDomain: 'acme.com', labelIds: ['lab-cold'], labelNames: ['Cold'] }],
705
+ pagination: { page: 1, perPage: 100, totalEntries: 1, totalPages: 1 },
706
+ };
707
+ it('requires a list name', async () => {
708
+ const { result } = await run(ApolloGetListAccountsAction, {}, fakeClient({}));
709
+ expect(result.ResultCode).toBe('MISSING_REQUIRED_FIELDS');
710
+ });
711
+ it('resolves the list name to an id and filters on it', async () => {
712
+ const searchAccounts = vi.fn(async () => page);
713
+ const { result, params } = await run(ApolloGetListAccountsAction, { ListName: 'Cold', Keywords: 'assoc', Page: '2', PerPage: '50' }, fakeClient({ findLabelByName: async () => LABELS[0], searchAccounts }));
714
+ expect(result.Success).toBe(true);
715
+ expect(searchAccounts).toHaveBeenCalledWith({ labelIds: ['lab-cold'], keywords: 'assoc' }, { page: 2, perPage: 50 });
716
+ expect(output(params, 'ListID')).toBe('lab-cold');
717
+ expect(output(params, 'ResolvedListName')).toBe('Cold');
718
+ expect(output(params, 'Count')).toBe(1);
719
+ });
720
+ it('points the caller at Get Lists when the name does not resolve', async () => {
721
+ const { result } = await run(ApolloGetListAccountsAction, { ListName: 'Typo' }, fakeClient({ findLabelByName: async () => null }));
722
+ expect(result.ResultCode).toBe('NOT_FOUND');
723
+ expect(result.Message).toMatch(/Get Lists/);
724
+ });
725
+ it('rejects bad paging before resolving anything', async () => {
726
+ const findLabelByName = vi.fn();
727
+ const { result } = await run(ApolloGetListAccountsAction, { ListName: 'Cold', Page: '0' }, fakeClient({ findLabelByName: findLabelByName }));
728
+ expect(result.ResultCode).toBe('VALIDATION_ERROR');
729
+ expect(findLabelByName).not.toHaveBeenCalled();
730
+ });
731
+ it('returns the labels a move will need to preserve', async () => {
732
+ const { params } = await run(ApolloGetListAccountsAction, { ListName: 'Cold' }, fakeClient({ findLabelByName: async () => LABELS[0], searchAccounts: async () => page }));
733
+ expect(output(params, 'Accounts')[0].labelNames).toEqual(['Cold']);
734
+ });
735
+ });
736
+ describe('ApolloGetListContactsAction', () => {
737
+ const page = {
738
+ contacts: [
739
+ {
740
+ id: 'con-1',
741
+ firstName: 'A',
742
+ lastName: 'B',
743
+ name: 'A B',
744
+ title: 'VP',
745
+ email: 'a@b.com',
746
+ organizationName: 'Acme',
747
+ labelIds: ['lab-cold'],
748
+ labelNames: ['Cold'],
749
+ },
750
+ ],
751
+ pagination: { page: 1, perPage: 100, totalEntries: 1, totalPages: 1 },
752
+ };
753
+ it('filters contacts on the resolved list id', async () => {
754
+ const searchContacts = vi.fn(async () => page);
755
+ const { result, params } = await run(ApolloGetListContactsAction, { ListName: 'Cold', Keywords: 'director' }, fakeClient({ findLabelByName: async () => LABELS[0], searchContacts }));
756
+ expect(result.Success).toBe(true);
757
+ expect(searchContacts).toHaveBeenCalledWith({ labelIds: ['lab-cold'], keywords: 'director' }, {});
758
+ expect(output(params, 'Count')).toBe(1);
759
+ });
760
+ });
761
+ describe('ApolloSearchPeopleAction', () => {
762
+ const page = {
763
+ people: [
764
+ { id: 'p-1', firstName: 'A', lastName: 'B', name: 'A B', title: 'VP', linkedinUrl: '', organizationId: 'org-1', organizationName: 'Acme' },
765
+ ],
766
+ pagination: { page: 1, perPage: 100, totalEntries: 1, totalPages: 1 },
767
+ };
768
+ it('refuses an unscoped search rather than burning the rate limit', async () => {
769
+ const searchPeople = vi.fn();
770
+ const { result } = await run(ApolloSearchPeopleAction, {}, fakeClient({ searchPeople: searchPeople }));
771
+ expect(result.ResultCode).toBe('VALIDATION_ERROR');
772
+ expect(result.Message).toMatch(/at least one of/);
773
+ expect(searchPeople).not.toHaveBeenCalled();
774
+ });
775
+ it('builds the filter from whichever inputs were supplied', async () => {
776
+ const searchPeople = vi.fn(async () => page);
777
+ await run(ApolloSearchPeopleAction, { Titles: 'VP of Marketing, CMO', Seniorities: '["vp"]' }, fakeClient({ searchPeople }));
778
+ expect(searchPeople).toHaveBeenCalledWith({ titles: ['VP of Marketing', 'CMO'], seniorities: ['vp'] }, {});
779
+ });
780
+ it('says in the message that emails are not part of this answer', async () => {
781
+ const { result } = await run(ApolloSearchPeopleAction, { Titles: 'CEO' }, fakeClient({ searchPeople: async () => page }));
782
+ expect(result.Success).toBe(true);
783
+ expect(result.Message).toMatch(/Emails and phones are not returned/);
784
+ });
785
+ it('reports a malformed list input as a validation error', async () => {
786
+ const { result } = await run(ApolloSearchPeopleAction, { Titles: '["CEO"' }, fakeClient({}));
787
+ expect(result.ResultCode).toBe('VALIDATION_ERROR');
788
+ });
789
+ });
790
+ describe('ApolloMoveListAccountsAction', () => {
791
+ const sourcePage = {
792
+ accounts: [
793
+ { id: 'acc-1', name: 'Acme', primaryDomain: '', labelIds: ['lab-cold'], labelNames: ['Cold', 'Do Not Touch'] },
794
+ { id: 'acc-2', name: 'Globex', primaryDomain: '', labelIds: ['lab-cold'], labelNames: ['Cold'] },
795
+ ],
796
+ pagination: { page: 1, perPage: 100, totalEntries: 2, totalPages: 1 },
797
+ };
798
+ const moveResult = {
799
+ movedCount: 1,
800
+ failedCount: 0,
801
+ possiblyStuckCount: 0,
802
+ items: [{ id: 'acc-1', added: true, removed: true, possiblyStuck: false, error: null }],
803
+ };
804
+ function moveClient(overrides = {}) {
805
+ return fakeClient({
806
+ findLabelByName: async (name) => LABELS.find((l) => l.name === name) ?? null,
807
+ searchAccounts: async () => sourcePage,
808
+ moveAccounts: async () => moveResult,
809
+ ...overrides,
810
+ });
811
+ }
812
+ it('requires the ids and both list names', async () => {
813
+ expect((await run(ApolloMoveListAccountsAction, { FromList: 'Cold', ToList: 'Warm' }, fakeClient({}))).result.ResultCode).toBe('MISSING_REQUIRED_FIELDS');
814
+ expect((await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', ToList: 'Warm' }, fakeClient({}))).result.ResultCode).toBe('MISSING_REQUIRED_FIELDS');
815
+ expect((await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold' }, fakeClient({}))).result.ResultCode).toBe('MISSING_REQUIRED_FIELDS');
816
+ });
817
+ it('refuses a move onto the same list', async () => {
818
+ const { result } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold', ToList: ' cold ' }, fakeClient({}));
819
+ expect(result.ResultCode).toBe('VALIDATION_ERROR');
820
+ });
821
+ it('resolves both list names before writing anything', async () => {
822
+ // A typo in ToList caught halfway through would leave the batch split.
823
+ const moveAccounts = vi.fn();
824
+ const { result } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold', ToList: 'Typo' }, moveClient({ moveAccounts: moveAccounts }));
825
+ expect(result.ResultCode).toBe('NOT_FOUND');
826
+ expect(result.Message).toMatch(/'Typo' \(ToList\)/);
827
+ expect(moveAccounts).not.toHaveBeenCalled();
828
+ });
829
+ it('moves the records with the labels it just read, not with anything the caller supplied', async () => {
830
+ const moveAccounts = vi.fn(async () => moveResult);
831
+ const { result } = await run(ApolloMoveListAccountsAction, { AccountIDs: '["acc-1"]', FromList: 'Cold', ToList: 'Warm' }, moveClient({ moveAccounts }));
832
+ expect(result.Success).toBe(true);
833
+ const [records, from, to, options] = moveAccounts.mock.calls[0];
834
+ expect(records).toEqual([sourcePage.accounts[0]]);
835
+ expect(records[0].labelNames).toEqual(['Cold', 'Do Not Touch']);
836
+ expect([from, to]).toEqual(['Cold', 'Warm']);
837
+ expect(options).toEqual({ verify: true });
838
+ });
839
+ it('reads page 1 of the source list, because removals shift later pages', async () => {
840
+ const searchAccounts = vi.fn(async () => sourcePage);
841
+ await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold', ToList: 'Warm' }, moveClient({ searchAccounts }));
842
+ expect(searchAccounts).toHaveBeenCalledWith({ labelIds: ['lab-cold'] }, { page: 1, perPage: 100 });
843
+ });
844
+ it('reports skipped ids as a PARTIAL_FAILURE rather than a clean success', async () => {
845
+ // The batch is still NOT aborted — acc-1 moves, and NotOnSourcePage names what did not.
846
+ // But it is no longer `Success: true`: only page 1 of the source list is read, so ids
847
+ // beyond it are never attempted, and the caller asked for work that did not happen.
848
+ //
849
+ // The message used to read `Moved 1 of 1`, counted against what page 1 contained rather
850
+ // than what was requested — so 300 ids could report `Moved 100 of 100` with Success true
851
+ // while 200 were silently skipped.
852
+ const { result, params } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1, acc-999', FromList: 'Cold', ToList: 'Warm' }, moveClient());
853
+ expect(result.Success).toBe(false);
854
+ expect(result.ResultCode).toBe('PARTIAL_FAILURE');
855
+ expect(output(params, 'NotOnSourcePage')).toEqual(['acc-999']);
856
+ // Counted against what was REQUESTED (2), with the skip named in the caller-facing message.
857
+ expect(result.Message).toContain('of 2');
858
+ expect(result.Message).toMatch(/1 not on page 1 .*NOT attempted/);
859
+ });
860
+ it('de-duplicates the supplied ids', async () => {
861
+ const moveAccounts = vi.fn(async () => moveResult);
862
+ await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1, acc-1', FromList: 'Cold', ToList: 'Warm' }, moveClient({ moveAccounts }));
863
+ expect(moveAccounts.mock.calls[0][0]).toHaveLength(1);
864
+ });
865
+ it('explains what to do when none of the ids are on the source page', async () => {
866
+ const { result } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-999', FromList: 'Cold', ToList: 'Warm' }, moveClient());
867
+ expect(result.ResultCode).toBe('NOT_FOUND');
868
+ expect(result.Message).toMatch(/re-read page 1/);
869
+ });
870
+ it('reports a partial failure when a write failed', async () => {
871
+ const { result, params } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold', ToList: 'Warm' }, moveClient({
872
+ moveAccounts: async () => ({
873
+ movedCount: 0,
874
+ failedCount: 1,
875
+ possiblyStuckCount: 0,
876
+ items: [{ id: 'acc-1', added: true, removed: false, possiblyStuck: null, error: 'remove phase: boom' }],
877
+ }),
878
+ }));
879
+ expect(result.Success).toBe(false);
880
+ expect(result.ResultCode).toBe('PARTIAL_FAILURE');
881
+ expect(output(params, 'FailedCount')).toBe(1);
882
+ });
883
+ it('treats a stuck record as a success with an explanation, not as a failure', async () => {
884
+ // The write succeeded; Apollo did not honour it. Reporting it as a failure
885
+ // would send the caller looking for a bug in the request.
886
+ const { result, params } = await run(ApolloMoveListAccountsAction, { AccountIDs: 'acc-1', FromList: 'Cold', ToList: 'Warm' }, moveClient({
887
+ moveAccounts: async () => ({
888
+ movedCount: 0,
889
+ failedCount: 0,
890
+ possiblyStuckCount: 1,
891
+ items: [{ id: 'acc-1', added: true, removed: true, possiblyStuck: true, error: null }],
892
+ }),
893
+ }));
894
+ expect(result.Success).toBe(true);
895
+ expect(result.ResultCode).toBe('SUCCESS');
896
+ expect(output(params, 'PossiblyStuckCount')).toBe(1);
897
+ expect(result.Message).toMatch(/next page-1 drain/);
898
+ expect(result.Message).toMatch(/does not attempt one/);
899
+ });
900
+ });
901
+ describe('ApolloMoveListContactsAction', () => {
902
+ const sourcePage = {
903
+ contacts: [
904
+ {
905
+ id: 'con-1',
906
+ firstName: 'A',
907
+ lastName: 'B',
908
+ name: 'A B',
909
+ title: 'VP',
910
+ email: 'a@b.com',
911
+ organizationName: 'Acme',
912
+ labelIds: ['lab-cold'],
913
+ labelNames: ['Cold'],
914
+ },
915
+ ],
916
+ pagination: { page: 1, perPage: 100, totalEntries: 1, totalPages: 1 },
917
+ };
918
+ it('moves contacts through the contact search and move surface', async () => {
919
+ const moveContacts = vi.fn(async () => ({
920
+ movedCount: 1,
921
+ failedCount: 0,
922
+ possiblyStuckCount: 0,
923
+ items: [{ id: 'con-1', added: true, removed: true, possiblyStuck: false, error: null }],
924
+ }));
925
+ const { result, params } = await run(ApolloMoveListContactsAction, { ContactIDs: 'con-1', FromList: 'Cold', ToList: 'Warm' }, fakeClient({
926
+ findLabelByName: async (name) => LABELS.find((l) => l.name === name) ?? null,
927
+ searchContacts: async () => sourcePage,
928
+ moveContacts,
929
+ }));
930
+ expect(result.Success).toBe(true);
931
+ expect(output(params, 'MovedCount')).toBe(1);
932
+ expect(moveContacts.mock.calls[0][0]).toEqual([sourcePage.contacts[0]]);
933
+ });
934
+ it('names its own id param in the missing-field message', async () => {
935
+ const { result } = await run(ApolloMoveListContactsAction, { FromList: 'Cold', ToList: 'Warm' }, fakeClient({}));
936
+ expect(result.Message).toMatch(/ContactIDs/);
937
+ });
938
+ });
939
+ // ── Credential resolution through an action ───────────────────────────────────
940
+ describe('key resolution', () => {
941
+ it('falls back to the environment when no CompanyID is supplied', async () => {
942
+ const { result, params } = await run(ApolloGetListsAction, {}, fakeClient({ listLabels: async () => LABELS }));
943
+ expect(result.Success).toBe(true);
944
+ expect(output(params, 'KeySource')).toBe('environment');
945
+ });
946
+ it('fails with CREDENTIALS_NOT_FOUND when nothing is configured at all', async () => {
947
+ delete process.env.APOLLO_API_KEY;
948
+ vi.resetModules();
949
+ const { ApolloGetListsAction: Fresh } = await import('../lists/ApolloListActions.js');
950
+ const { result } = await run(Fresh, {}, fakeClient({}));
951
+ expect(result.Success).toBe(false);
952
+ expect(result.ResultCode).toBe('CREDENTIALS_NOT_FOUND');
953
+ expect(result.Message).toMatch(/MASTER Apollo key/);
954
+ expect(result.Message).toMatch(/APOLLO_API_KEY/);
955
+ });
956
+ });
957
+ //# sourceMappingURL=apollo-lists.test.js.map