instant-cli 1.0.33 → 1.0.34

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 (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__tests__/multiSelect.test.ts +236 -0
  3. package/__tests__/select.test.ts +224 -0
  4. package/__tests__/webhooks.test.ts +728 -0
  5. package/dist/commands/webhooks/add.d.ts +9 -0
  6. package/dist/commands/webhooks/add.d.ts.map +1 -0
  7. package/dist/commands/webhooks/add.js +75 -0
  8. package/dist/commands/webhooks/add.js.map +1 -0
  9. package/dist/commands/webhooks/delete.d.ts +6 -0
  10. package/dist/commands/webhooks/delete.d.ts.map +1 -0
  11. package/dist/commands/webhooks/delete.js +17 -0
  12. package/dist/commands/webhooks/delete.js.map +1 -0
  13. package/dist/commands/webhooks/disable.d.ts +7 -0
  14. package/dist/commands/webhooks/disable.d.ts.map +1 -0
  15. package/dist/commands/webhooks/disable.js +18 -0
  16. package/dist/commands/webhooks/disable.js.map +1 -0
  17. package/dist/commands/webhooks/enable.d.ts +6 -0
  18. package/dist/commands/webhooks/enable.d.ts.map +1 -0
  19. package/dist/commands/webhooks/enable.js +18 -0
  20. package/dist/commands/webhooks/enable.js.map +1 -0
  21. package/dist/commands/webhooks/events/list.d.ts +7 -0
  22. package/dist/commands/webhooks/events/list.d.ts.map +1 -0
  23. package/dist/commands/webhooks/events/list.js +31 -0
  24. package/dist/commands/webhooks/events/list.js.map +1 -0
  25. package/dist/commands/webhooks/events/payload.d.ts +8 -0
  26. package/dist/commands/webhooks/events/payload.d.ts.map +1 -0
  27. package/dist/commands/webhooks/events/payload.js +39 -0
  28. package/dist/commands/webhooks/events/payload.js.map +1 -0
  29. package/dist/commands/webhooks/events/resend.d.ts +8 -0
  30. package/dist/commands/webhooks/events/resend.d.ts.map +1 -0
  31. package/dist/commands/webhooks/events/resend.js +43 -0
  32. package/dist/commands/webhooks/events/resend.js.map +1 -0
  33. package/dist/commands/webhooks/list.d.ts +8 -0
  34. package/dist/commands/webhooks/list.d.ts.map +1 -0
  35. package/dist/commands/webhooks/list.js +29 -0
  36. package/dist/commands/webhooks/list.js.map +1 -0
  37. package/dist/commands/webhooks/shared.d.ts +40 -0
  38. package/dist/commands/webhooks/shared.d.ts.map +1 -0
  39. package/dist/commands/webhooks/shared.js +248 -0
  40. package/dist/commands/webhooks/shared.js.map +1 -0
  41. package/dist/commands/webhooks/update.d.ts +10 -0
  42. package/dist/commands/webhooks/update.d.ts.map +1 -0
  43. package/dist/commands/webhooks/update.js +189 -0
  44. package/dist/commands/webhooks/update.js.map +1 -0
  45. package/dist/index.d.ts +45 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +141 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/layer.d.ts +2 -2
  50. package/dist/layer.d.ts.map +1 -1
  51. package/dist/layer.js +30 -1
  52. package/dist/layer.js.map +1 -1
  53. package/dist/lib/webhooks.d.ts +28 -0
  54. package/dist/lib/webhooks.d.ts.map +1 -0
  55. package/dist/lib/webhooks.js +102 -0
  56. package/dist/lib/webhooks.js.map +1 -0
  57. package/dist/ui/index.d.ts +39 -1
  58. package/dist/ui/index.d.ts.map +1 -1
  59. package/dist/ui/index.js +387 -25
  60. package/dist/ui/index.js.map +1 -1
  61. package/dist/ui/lib.d.ts +7 -0
  62. package/dist/ui/lib.d.ts.map +1 -1
  63. package/dist/ui/lib.js +40 -1
  64. package/dist/ui/lib.js.map +1 -1
  65. package/package.json +4 -4
  66. package/src/commands/webhooks/add.ts +111 -0
  67. package/src/commands/webhooks/delete.ts +23 -0
  68. package/src/commands/webhooks/disable.ts +24 -0
  69. package/src/commands/webhooks/enable.ts +24 -0
  70. package/src/commands/webhooks/events/list.ts +38 -0
  71. package/src/commands/webhooks/events/payload.ts +56 -0
  72. package/src/commands/webhooks/events/resend.ts +66 -0
  73. package/src/commands/webhooks/list.ts +41 -0
  74. package/src/commands/webhooks/shared.ts +339 -0
  75. package/src/commands/webhooks/update.ts +276 -0
  76. package/src/index.ts +242 -0
  77. package/src/layer.ts +33 -1
  78. package/src/lib/webhooks.ts +127 -0
  79. package/src/ui/index.ts +465 -32
  80. package/src/ui/lib.ts +41 -1
@@ -0,0 +1,728 @@
1
+ import { test, expect, describe, vi, beforeEach } from 'vitest';
2
+ import { Effect, Layer, Logger } from 'effect';
3
+ import { GlobalOpts } from '../src/context/globalOpts.ts';
4
+ import { AuthToken } from '../src/context/authToken.ts';
5
+ import { CurrentApp } from '../src/context/currentApp.ts';
6
+
7
+ vi.mock('../src/index.ts', () => ({}));
8
+
9
+ const state = vi.hoisted(() => ({
10
+ manager: undefined as any,
11
+ namespaces: ['posts', 'comments', 'authors'] as string[] | null,
12
+ promptResponses: [] as unknown[],
13
+ }));
14
+
15
+ // Mock at the SDK boundary: any `new InstantPlatformApi(...)` returns a stub
16
+ // whose `.webhooks(appId).manager` is the per-test fake manager, and whose
17
+ // `getSchema` reflects state.namespaces (null → reject, simulating an auth /
18
+ // network / missing-app failure that getRemoteNamespaces catches).
19
+ vi.mock('@instantdb/platform', async (importOriginal) => {
20
+ const orig: any = await importOriginal();
21
+ return {
22
+ ...orig,
23
+ PlatformApi: class {
24
+ webhooks(_appId: string) {
25
+ return { manager: state.manager };
26
+ }
27
+ async getSchema(_appId: string) {
28
+ if (state.namespaces === null) {
29
+ throw new Error('schema unavailable');
30
+ }
31
+ const entities = Object.fromEntries(
32
+ state.namespaces.map((name) => [name, {}]),
33
+ );
34
+ return { schema: { entities } };
35
+ }
36
+ },
37
+ };
38
+ });
39
+
40
+ vi.mock('../src/ui/lib.ts', async (importOriginal) => {
41
+ const orig: any = await importOriginal();
42
+ return {
43
+ ...orig,
44
+ renderUnwrap: () => {
45
+ if (state.promptResponses.length === 0) {
46
+ return Promise.reject(new Error('No prompt response queued'));
47
+ }
48
+ return Promise.resolve(state.promptResponses.shift());
49
+ },
50
+ };
51
+ });
52
+
53
+ const { parseNamespaces, parseActions, fetchRecentEvents } = await import(
54
+ '../src/lib/webhooks.ts'
55
+ );
56
+ const { joinNamespaces, joinActions } = await import(
57
+ '../src/commands/webhooks/shared.ts'
58
+ );
59
+ const { webhooksListCmd } = await import('../src/commands/webhooks/list.ts');
60
+ const { webhooksAddCmd } = await import('../src/commands/webhooks/add.ts');
61
+ const { webhooksUpdateCmd } = await import(
62
+ '../src/commands/webhooks/update.ts'
63
+ );
64
+ const { webhooksDeleteCmd } = await import(
65
+ '../src/commands/webhooks/delete.ts'
66
+ );
67
+ const { webhooksEnableCmd } = await import(
68
+ '../src/commands/webhooks/enable.ts'
69
+ );
70
+ const { webhooksDisableCmd } = await import(
71
+ '../src/commands/webhooks/disable.ts'
72
+ );
73
+ const { webhooksEventsListCmd } = await import(
74
+ '../src/commands/webhooks/events/list.ts'
75
+ );
76
+ const { webhooksEventsResendCmd } = await import(
77
+ '../src/commands/webhooks/events/resend.ts'
78
+ );
79
+ const { webhooksEventsPayloadCmd } = await import(
80
+ '../src/commands/webhooks/events/payload.ts'
81
+ );
82
+
83
+ let logs: string[] = [];
84
+
85
+ const makeWebhook = (overrides: any = {}) => ({
86
+ id: 'wh1',
87
+ sink: { url: 'https://example.com' },
88
+ namespaces: ['posts'],
89
+ actions: ['create'],
90
+ status: 'active' as const,
91
+ disabledReason: null,
92
+ createdAt: new Date(),
93
+ updatedAt: new Date(),
94
+ ...overrides,
95
+ });
96
+
97
+ const makeEvent = (overrides: any = {}) => ({
98
+ isn: 'isn1',
99
+ status: 'success' as const,
100
+ attempts: null,
101
+ nextAttemptAfter: null,
102
+ createdAt: new Date('2026-05-14T10:00:00Z'),
103
+ updatedAt: new Date('2026-05-14T10:00:01Z'),
104
+ ...overrides,
105
+ });
106
+
107
+ const eventsPage = (
108
+ events: any[],
109
+ hasNextPage = false,
110
+ endCursor: string | null = null,
111
+ ) => ({
112
+ events,
113
+ pageInfo: {
114
+ startCursor: events[0]?.isn ?? null,
115
+ endCursor,
116
+ hasNextPage,
117
+ },
118
+ });
119
+
120
+ const buildManager = (
121
+ overrides: {
122
+ list?: any[];
123
+ createReturns?: any;
124
+ updateReturns?: any;
125
+ deleteReturns?: any;
126
+ enableReturns?: any;
127
+ disableReturns?: any;
128
+ listEventsReturns?: any;
129
+ resendReturns?: any;
130
+ payloadReturns?: any;
131
+ } = {},
132
+ ) => ({
133
+ list: vi.fn(async () => overrides.list ?? []),
134
+ create: vi.fn(
135
+ async (p: any) =>
136
+ overrides.createReturns ?? makeWebhook({ ...p, id: 'new-id' }),
137
+ ),
138
+ update: vi.fn(
139
+ async (id: string, p: any) =>
140
+ overrides.updateReturns ?? makeWebhook({ ...p, id }),
141
+ ),
142
+ delete: vi.fn(
143
+ async (id: string) => overrides.deleteReturns ?? makeWebhook({ id }),
144
+ ),
145
+ enable: vi.fn(
146
+ async (id: string) =>
147
+ overrides.enableReturns ?? makeWebhook({ id, status: 'active' }),
148
+ ),
149
+ disable: vi.fn(
150
+ async (id: string, opts?: { reason?: string }) =>
151
+ overrides.disableReturns ??
152
+ makeWebhook({
153
+ id,
154
+ status: 'disabled',
155
+ disabledReason: opts?.reason ?? null,
156
+ }),
157
+ ),
158
+ listEvents: vi.fn(
159
+ async (_webhookId: string, _opts?: { after?: string }) =>
160
+ overrides.listEventsReturns ?? eventsPage([]),
161
+ ),
162
+ resendEvent: vi.fn(
163
+ async (_webhookId: string, isn: string) =>
164
+ overrides.resendReturns ?? makeEvent({ isn, status: 'pending' }),
165
+ ),
166
+ getPayload: vi.fn(
167
+ async (_webhookId: string, _isn: string) =>
168
+ overrides.payloadReturns ?? {
169
+ records: [{ namespace: 'posts', action: 'create' }],
170
+ },
171
+ ),
172
+ });
173
+
174
+ const run = (effect: any, opts: { yes: boolean }) =>
175
+ Effect.runPromise(
176
+ effect.pipe(
177
+ Effect.provide(
178
+ Layer.mergeAll(
179
+ Layer.succeed(GlobalOpts, { yes: opts.yes }),
180
+ Layer.succeed(AuthToken, {
181
+ getAuthToken: Effect.succeed('test-token'),
182
+ getSource: Effect.succeed('env' as const),
183
+ setAuthToken: () => Effect.succeed(undefined),
184
+ }),
185
+ Layer.succeed(CurrentApp, {
186
+ appId: 'test-app',
187
+ source: 'env' as const,
188
+ }),
189
+ Logger.replace(
190
+ Logger.defaultLogger,
191
+ Logger.make(({ message }) => {
192
+ logs.push(String(message));
193
+ }),
194
+ ),
195
+ ),
196
+ ),
197
+ ),
198
+ );
199
+
200
+ beforeEach(() => {
201
+ logs = [];
202
+ state.manager = buildManager();
203
+ state.namespaces = ['posts', 'comments', 'authors'];
204
+ state.promptResponses = [];
205
+ });
206
+
207
+ describe('parseNamespaces', () => {
208
+ test('undefined input returns undefined', async () => {
209
+ expect(await Effect.runPromise(parseNamespaces(undefined))).toBeUndefined();
210
+ });
211
+ test('parses CSV', async () => {
212
+ expect(await Effect.runPromise(parseNamespaces('posts,comments'))).toEqual([
213
+ 'posts',
214
+ 'comments',
215
+ ]);
216
+ });
217
+ test('trims whitespace and drops empties', async () => {
218
+ expect(
219
+ await Effect.runPromise(parseNamespaces(' posts , , comments ')),
220
+ ).toEqual(['posts', 'comments']);
221
+ });
222
+ test('empty string errors', async () => {
223
+ const err: any = await Effect.runPromise(Effect.flip(parseNamespaces('')));
224
+ expect(err.message).toMatch(/at least one namespace/);
225
+ });
226
+ });
227
+
228
+ describe('parseActions', () => {
229
+ test('undefined returns undefined', async () => {
230
+ expect(await Effect.runPromise(parseActions(undefined))).toBeUndefined();
231
+ });
232
+ test('parses valid actions', async () => {
233
+ expect(await Effect.runPromise(parseActions('create,update'))).toEqual([
234
+ 'create',
235
+ 'update',
236
+ ]);
237
+ });
238
+ test('rejects invalid action', async () => {
239
+ const err: any = await Effect.runPromise(
240
+ Effect.flip(parseActions('create,nuke')),
241
+ );
242
+ expect(err.message).toMatch(/Invalid action: nuke/);
243
+ });
244
+ test('rejects multiple invalid actions', async () => {
245
+ const err: any = await Effect.runPromise(
246
+ Effect.flip(parseActions('foo,bar')),
247
+ );
248
+ expect(err.message).toMatch(/Invalid actions: foo, bar/);
249
+ });
250
+ test('rejects empty', async () => {
251
+ const err: any = await Effect.runPromise(Effect.flip(parseActions('')));
252
+ expect(err.message).toMatch(/at least one action/);
253
+ });
254
+ });
255
+
256
+ describe('webhook list', () => {
257
+ test('lists webhooks in human format', async () => {
258
+ state.manager = buildManager({ list: [makeWebhook()] });
259
+ await run(webhooksListCmd({} as any), { yes: false });
260
+ const out = logs.join('\n');
261
+ expect(out).toContain('https://example.com');
262
+ expect(out).toContain('ID: wh1');
263
+ expect(out).toContain('Namespaces: posts');
264
+ expect(out).toContain('Actions: create');
265
+ expect(out).toContain('Status: active');
266
+ });
267
+ test('shows empty message', async () => {
268
+ await run(webhooksListCmd({} as any), { yes: false });
269
+ expect(logs.join('\n')).toContain('No webhooks configured');
270
+ });
271
+ test('--json prints raw array', async () => {
272
+ state.manager = buildManager({ list: [makeWebhook()] });
273
+ await run(webhooksListCmd({ json: true } as any), { yes: false });
274
+ const parsed = JSON.parse(logs.join('\n'));
275
+ expect(parsed[0]).toMatchObject({ id: 'wh1' });
276
+ });
277
+ });
278
+
279
+ describe('webhook add --yes', () => {
280
+ test('happy path calls create with parsed flags', async () => {
281
+ await run(
282
+ webhooksAddCmd({
283
+ url: 'https://hook.example.com',
284
+ namespaces: 'posts,comments',
285
+ actions: 'create,update',
286
+ } as any),
287
+ { yes: true },
288
+ );
289
+ expect(state.manager.create).toHaveBeenCalledWith({
290
+ url: 'https://hook.example.com',
291
+ namespaces: ['posts', 'comments'],
292
+ actions: ['create', 'update'],
293
+ });
294
+ expect(logs.join('\n')).toContain('Webhook added');
295
+ });
296
+ test('missing --namespaces errors and does not call create', async () => {
297
+ await run(
298
+ webhooksAddCmd({
299
+ url: 'https://hook.example.com',
300
+ actions: 'create',
301
+ } as any),
302
+ { yes: true },
303
+ );
304
+ expect(state.manager.create).not.toHaveBeenCalled();
305
+ expect(logs.join('\n')).toMatch(/--namespaces/);
306
+ });
307
+ test('missing --actions errors and does not call create', async () => {
308
+ await run(
309
+ webhooksAddCmd({
310
+ url: 'https://hook.example.com',
311
+ namespaces: 'posts',
312
+ } as any),
313
+ { yes: true },
314
+ );
315
+ expect(state.manager.create).not.toHaveBeenCalled();
316
+ expect(logs.join('\n')).toMatch(/--actions/);
317
+ });
318
+ test('missing --url errors and does not call create', async () => {
319
+ await run(
320
+ webhooksAddCmd({
321
+ namespaces: 'posts',
322
+ actions: 'create',
323
+ } as any),
324
+ { yes: true },
325
+ );
326
+ expect(state.manager.create).not.toHaveBeenCalled();
327
+ expect(logs.join('\n')).toMatch(/--url/);
328
+ });
329
+ });
330
+
331
+ describe('webhook delete', () => {
332
+ test('deletes when --id provided', async () => {
333
+ await run(webhooksDeleteCmd({ id: 'wh1' } as any), { yes: true });
334
+ expect(state.manager.delete).toHaveBeenCalledWith('wh1');
335
+ expect(logs.join('\n')).toContain('Webhook deleted');
336
+ });
337
+ });
338
+
339
+ describe('webhook enable', () => {
340
+ test('enables when --id provided', async () => {
341
+ await run(webhooksEnableCmd({ id: 'wh1' } as any), { yes: true });
342
+ expect(state.manager.enable).toHaveBeenCalledWith('wh1');
343
+ expect(logs.join('\n')).toContain('Webhook enabled');
344
+ });
345
+ });
346
+
347
+ describe('webhook disable', () => {
348
+ test('without reason', async () => {
349
+ await run(webhooksDisableCmd({ id: 'wh1' } as any), { yes: true });
350
+ expect(state.manager.disable).toHaveBeenCalledWith('wh1', undefined);
351
+ expect(logs.join('\n')).toContain('Webhook disabled');
352
+ });
353
+ test('with reason', async () => {
354
+ await run(webhooksDisableCmd({ id: 'wh1', reason: 'flaky' } as any), {
355
+ yes: true,
356
+ });
357
+ expect(state.manager.disable).toHaveBeenCalledWith('wh1', {
358
+ reason: 'flaky',
359
+ });
360
+ expect(logs.join('\n')).toContain('Disabled reason: flaky');
361
+ });
362
+ });
363
+
364
+ describe('webhook update --yes', () => {
365
+ test('partial url-only update', async () => {
366
+ await run(
367
+ webhooksUpdateCmd({
368
+ id: 'wh1',
369
+ url: 'https://new.example.com',
370
+ } as any),
371
+ { yes: true },
372
+ );
373
+ expect(state.manager.update).toHaveBeenCalledWith('wh1', {
374
+ url: 'https://new.example.com',
375
+ });
376
+ });
377
+ test('all fields update', async () => {
378
+ await run(
379
+ webhooksUpdateCmd({
380
+ id: 'wh1',
381
+ url: 'https://new.example.com',
382
+ namespaces: 'foo',
383
+ actions: 'create,delete',
384
+ } as any),
385
+ { yes: true },
386
+ );
387
+ expect(state.manager.update).toHaveBeenCalledWith('wh1', {
388
+ url: 'https://new.example.com',
389
+ namespaces: ['foo'],
390
+ actions: ['create', 'delete'],
391
+ });
392
+ });
393
+ test('no fields errors and does not call update', async () => {
394
+ await run(webhooksUpdateCmd({ id: 'wh1' } as any), { yes: true });
395
+ expect(state.manager.update).not.toHaveBeenCalled();
396
+ expect(logs.join('\n')).toMatch(/at least one of/);
397
+ });
398
+ test('no --id errors and does not call update', async () => {
399
+ await run(webhooksUpdateCmd({ url: 'https://x.example.com' } as any), {
400
+ yes: true,
401
+ });
402
+ expect(state.manager.update).not.toHaveBeenCalled();
403
+ expect(logs.join('\n')).toMatch(/--id/);
404
+ });
405
+ test('explicit --url "" errors with URL-cannot-be-empty', async () => {
406
+ await run(webhooksUpdateCmd({ id: 'wh1', url: '' } as any), {
407
+ yes: true,
408
+ });
409
+ expect(state.manager.update).not.toHaveBeenCalled();
410
+ expect(logs.join('\n')).toMatch(/URL cannot be empty/);
411
+ });
412
+ });
413
+
414
+ describe('interactive flows', () => {
415
+ test('webhook add prompts for missing url/namespaces/actions', async () => {
416
+ state.promptResponses = [
417
+ 'https://my-hook.example.com', // URL TextInput
418
+ ['posts', 'authors'], // namespaces MultiSelect
419
+ ['create', 'update'], // actions MultiSelect
420
+ ];
421
+ await run(webhooksAddCmd({} as any), { yes: false });
422
+ expect(state.manager.create).toHaveBeenCalledWith({
423
+ url: 'https://my-hook.example.com',
424
+ namespaces: ['posts', 'authors'],
425
+ actions: ['create', 'update'],
426
+ });
427
+ });
428
+
429
+ test('webhook add falls back to text-input for namespaces when schema unavailable', async () => {
430
+ state.namespaces = null;
431
+ state.promptResponses = [
432
+ 'https://x.example.com',
433
+ 'foo,bar', // namespaces TextInput
434
+ ['create'], // actions MultiSelect
435
+ ];
436
+ await run(webhooksAddCmd({} as any), { yes: false });
437
+ expect(state.manager.create).toHaveBeenCalledWith({
438
+ url: 'https://x.example.com',
439
+ namespaces: ['foo', 'bar'],
440
+ actions: ['create'],
441
+ });
442
+ });
443
+
444
+ test('webhook delete picker selects a webhook and deletes it', async () => {
445
+ const wh = makeWebhook({ id: 'pick-me' });
446
+ state.manager = buildManager({ list: [wh] });
447
+ state.promptResponses = [wh];
448
+ await run(webhooksDeleteCmd({} as any), { yes: false });
449
+ expect(state.manager.delete).toHaveBeenCalledWith('pick-me');
450
+ });
451
+
452
+ test('webhook update menu edits URL then saves', async () => {
453
+ const initial = makeWebhook({
454
+ id: 'wh1',
455
+ sink: { url: 'https://old.example.com' },
456
+ });
457
+ state.manager = buildManager({ list: [initial] });
458
+ state.promptResponses = [
459
+ initial, // picker selection
460
+ 'url', // menu pick
461
+ 'https://new.example.com', // URL TextInput
462
+ 'save', // menu pick
463
+ ];
464
+ await run(webhooksUpdateCmd({} as any), { yes: false });
465
+ expect(state.manager.update).toHaveBeenCalledWith('wh1', {
466
+ url: 'https://new.example.com',
467
+ });
468
+ });
469
+
470
+ test('webhook update menu cancel does not call update', async () => {
471
+ const initial = makeWebhook({ id: 'wh1' });
472
+ state.manager = buildManager({ list: [initial] });
473
+ state.promptResponses = [initial, 'cancel'];
474
+ await run(webhooksUpdateCmd({} as any), { yes: false });
475
+ expect(state.manager.update).not.toHaveBeenCalled();
476
+ expect(logs.join('\n')).toContain('Cancelled');
477
+ });
478
+
479
+ test('webhook update menu save with no changes does not call update', async () => {
480
+ const initial = makeWebhook({ id: 'wh1' });
481
+ state.manager = buildManager({ list: [initial] });
482
+ state.promptResponses = [initial, 'save'];
483
+ await run(webhooksUpdateCmd({} as any), { yes: false });
484
+ expect(state.manager.update).not.toHaveBeenCalled();
485
+ expect(logs.join('\n')).toContain('No changes to save');
486
+ });
487
+
488
+ test('webhook update with field flags skips the menu', async () => {
489
+ const initial = makeWebhook({ id: 'wh1' });
490
+ state.manager = buildManager({ list: [initial] });
491
+ state.promptResponses = [initial]; // only the picker
492
+ await run(webhooksUpdateCmd({ url: 'https://new.example.com' } as any), {
493
+ yes: false,
494
+ });
495
+ expect(state.manager.update).toHaveBeenCalledWith('wh1', {
496
+ url: 'https://new.example.com',
497
+ });
498
+ });
499
+ });
500
+
501
+ describe('joinNamespaces', () => {
502
+ test('sorts alphabetically', () => {
503
+ expect(joinNamespaces(['posts', 'authors', 'comments'])).toBe(
504
+ 'authors, comments, posts',
505
+ );
506
+ });
507
+ test('handles single namespace', () => {
508
+ expect(joinNamespaces(['posts'])).toBe('posts');
509
+ });
510
+ test('handles empty list', () => {
511
+ expect(joinNamespaces([])).toBe('');
512
+ });
513
+ });
514
+
515
+ describe('joinActions', () => {
516
+ test('returns canonical order regardless of input order', () => {
517
+ expect(joinActions(['delete', 'create'])).toBe('create, delete');
518
+ expect(joinActions(['update', 'delete', 'create'])).toBe(
519
+ 'create, update, delete',
520
+ );
521
+ });
522
+ test('handles single action', () => {
523
+ expect(joinActions(['update'])).toBe('update');
524
+ });
525
+ });
526
+
527
+ describe('fetchRecentEvents', () => {
528
+ const runWithLayer = (effect: any) =>
529
+ Effect.runPromise(
530
+ effect.pipe(
531
+ Effect.provide(
532
+ Layer.mergeAll(
533
+ Layer.succeed(GlobalOpts, { yes: true }),
534
+ Layer.succeed(AuthToken, {
535
+ getAuthToken: Effect.succeed('test-token'),
536
+ getSource: Effect.succeed('env' as const),
537
+ setAuthToken: () => Effect.succeed(undefined),
538
+ }),
539
+ Layer.succeed(CurrentApp, {
540
+ appId: 'test-app',
541
+ source: 'env' as const,
542
+ }),
543
+ Logger.replace(
544
+ Logger.defaultLogger,
545
+ Logger.make(({ message }) => logs.push(String(message))),
546
+ ),
547
+ ),
548
+ ),
549
+ ),
550
+ );
551
+
552
+ test('paginates until limit is satisfied', async () => {
553
+ const events1 = Array.from({ length: 50 }, (_, i) =>
554
+ makeEvent({ isn: `e${i}` }),
555
+ );
556
+ const events2 = Array.from({ length: 50 }, (_, i) =>
557
+ makeEvent({ isn: `e${50 + i}` }),
558
+ );
559
+ state.manager.listEvents = vi
560
+ .fn()
561
+ .mockResolvedValueOnce(eventsPage(events1, true, 'cursor-1'))
562
+ .mockResolvedValueOnce(eventsPage(events2, true, 'cursor-2'));
563
+ const result = (await runWithLayer(fetchRecentEvents('wh1', 100))) as any[];
564
+ expect(result).toHaveLength(100);
565
+ expect(state.manager.listEvents).toHaveBeenCalledTimes(2);
566
+ expect(state.manager.listEvents).toHaveBeenNthCalledWith(
567
+ 1,
568
+ 'wh1',
569
+ undefined,
570
+ );
571
+ expect(state.manager.listEvents).toHaveBeenNthCalledWith(2, 'wh1', {
572
+ after: 'cursor-1',
573
+ });
574
+ });
575
+
576
+ test('stops when hasNextPage is false', async () => {
577
+ const events = [makeEvent({ isn: 'a' }), makeEvent({ isn: 'b' })];
578
+ state.manager.listEvents = vi
579
+ .fn()
580
+ .mockResolvedValueOnce(eventsPage(events, false, null));
581
+ const result = (await runWithLayer(fetchRecentEvents('wh1', 100))) as any[];
582
+ expect(result).toHaveLength(2);
583
+ expect(state.manager.listEvents).toHaveBeenCalledTimes(1);
584
+ });
585
+
586
+ test('caps oversized pages at limit', async () => {
587
+ const events = Array.from({ length: 150 }, (_, i) =>
588
+ makeEvent({ isn: `e${i}` }),
589
+ );
590
+ state.manager.listEvents = vi
591
+ .fn()
592
+ .mockResolvedValueOnce(eventsPage(events, true, 'cursor'));
593
+ const result = (await runWithLayer(fetchRecentEvents('wh1', 100))) as any[];
594
+ expect(result).toHaveLength(100);
595
+ expect(state.manager.listEvents).toHaveBeenCalledTimes(1);
596
+ });
597
+ });
598
+
599
+ describe('webhook event list', () => {
600
+ test('--yes prints each event with status, attempts, timestamps', async () => {
601
+ state.manager.listEvents = vi.fn().mockResolvedValueOnce(
602
+ eventsPage(
603
+ [
604
+ makeEvent({
605
+ isn: 'evt-1',
606
+ status: 'failed',
607
+ attempts: [
608
+ {
609
+ attemptAt: new Date('2026-05-14T10:00:00Z'),
610
+ durationMs: 1234,
611
+ success: false,
612
+ statusCode: 503,
613
+ responseText: null,
614
+ errorType: null,
615
+ errorMessage: null,
616
+ },
617
+ ],
618
+ }),
619
+ ],
620
+ false,
621
+ null,
622
+ ),
623
+ );
624
+ await run(webhooksEventsListCmd({ webhookId: 'wh1' } as any), {
625
+ yes: true,
626
+ });
627
+ const out = logs.join('\n');
628
+ expect(out).toContain('evt-1');
629
+ expect(out).toContain('failed');
630
+ expect(out).toContain('Attempts: 1');
631
+ expect(out).toContain('503');
632
+ });
633
+
634
+ test('--json prints the raw events array', async () => {
635
+ state.manager.listEvents = vi
636
+ .fn()
637
+ .mockResolvedValueOnce(
638
+ eventsPage([makeEvent({ isn: 'a' })], false, null),
639
+ );
640
+ await run(webhooksEventsListCmd({ webhookId: 'wh1', json: true } as any), {
641
+ yes: true,
642
+ });
643
+ const parsed = JSON.parse(logs.join('\n'));
644
+ expect(parsed[0]).toMatchObject({ isn: 'a' });
645
+ });
646
+
647
+ test('empty list shows friendly message', async () => {
648
+ state.manager.listEvents = vi
649
+ .fn()
650
+ .mockResolvedValueOnce(eventsPage([], false, null));
651
+ await run(webhooksEventsListCmd({ webhookId: 'wh1' } as any), {
652
+ yes: true,
653
+ });
654
+ expect(logs.join('\n')).toContain('No events for this webhook');
655
+ });
656
+ });
657
+
658
+ describe('webhook event resend', () => {
659
+ test('--yes with --webhook-id and --isn calls resendEvent', async () => {
660
+ await run(
661
+ webhooksEventsResendCmd({ webhookId: 'wh1', isn: 'evt-1' } as any),
662
+ { yes: true },
663
+ );
664
+ expect(state.manager.resendEvent).toHaveBeenCalledWith('wh1', 'evt-1');
665
+ expect(logs.join('\n')).toContain('Resent event');
666
+ });
667
+
668
+ test('--yes without --isn errors via catchTag', async () => {
669
+ await run(webhooksEventsResendCmd({ webhookId: 'wh1' } as any), {
670
+ yes: true,
671
+ });
672
+ expect(state.manager.resendEvent).not.toHaveBeenCalled();
673
+ expect(logs.join('\n')).toMatch(/--isn/);
674
+ });
675
+
676
+ test('interactive: pickers for webhook and event', async () => {
677
+ const wh = makeWebhook({ id: 'pick-wh' });
678
+ const evt = makeEvent({ isn: 'pick-evt' });
679
+ state.manager = buildManager({
680
+ list: [wh],
681
+ listEventsReturns: eventsPage([evt], false, null),
682
+ });
683
+ state.promptResponses = [wh, evt];
684
+ await run(webhooksEventsResendCmd({} as any), { yes: false });
685
+ expect(state.manager.resendEvent).toHaveBeenCalledWith(
686
+ 'pick-wh',
687
+ 'pick-evt',
688
+ );
689
+ });
690
+ });
691
+
692
+ describe('webhook event payload', () => {
693
+ test('--yes prints prettified JSON', async () => {
694
+ state.manager.getPayload = vi.fn().mockResolvedValueOnce({
695
+ records: [{ namespace: 'posts', action: 'create' }],
696
+ });
697
+ await run(
698
+ webhooksEventsPayloadCmd({ webhookId: 'wh1', isn: 'evt-1' } as any),
699
+ { yes: true },
700
+ );
701
+ expect(state.manager.getPayload).toHaveBeenCalledWith('wh1', 'evt-1');
702
+ const parsed = JSON.parse(logs.join('\n'));
703
+ expect(parsed).toMatchObject({
704
+ records: [{ namespace: 'posts', action: 'create' }],
705
+ });
706
+ });
707
+
708
+ test('--yes without --isn errors', async () => {
709
+ await run(webhooksEventsPayloadCmd({ webhookId: 'wh1' } as any), {
710
+ yes: true,
711
+ });
712
+ expect(state.manager.getPayload).not.toHaveBeenCalled();
713
+ expect(logs.join('\n')).toMatch(/--isn/);
714
+ });
715
+
716
+ test('interactive: picker for event then prints payload', async () => {
717
+ const wh = makeWebhook({ id: 'wh1' });
718
+ const evt = makeEvent({ isn: 'evt-1' });
719
+ state.manager = buildManager({
720
+ list: [wh],
721
+ listEventsReturns: eventsPage([evt], false, null),
722
+ payloadReturns: { records: [] },
723
+ });
724
+ state.promptResponses = [wh, evt];
725
+ await run(webhooksEventsPayloadCmd({} as any), { yes: false });
726
+ expect(state.manager.getPayload).toHaveBeenCalledWith('wh1', 'evt-1');
727
+ });
728
+ });