@slates/test 1.0.0-rc.27 → 1.0.0-rc.29

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.
@@ -0,0 +1,618 @@
1
+ import {
2
+ Slate,
3
+ SlateAuth,
4
+ SlateConfig,
5
+ SlateSpecification,
6
+ SlateTrigger,
7
+ triggerGroup
8
+ } from '@slates/provider';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { z } from 'zod';
11
+ import {
12
+ authenticateSlateTriggerGroupWebhookDelivery,
13
+ createLocalSlateTestClient,
14
+ expandSlateTriggerGroupWebhook,
15
+ listSlateTriggerGroupSelectionOptions,
16
+ pollSlateTriggerGroupEvents,
17
+ processSlateTriggerGroupWebhook,
18
+ registerSlateTriggerGroupWebhook,
19
+ renewSlateTriggerGroupWebhook
20
+ } from './index';
21
+
22
+ let fixture = (
23
+ options: {
24
+ selection?: 'required' | 'default';
25
+ legacy?: boolean;
26
+ expiresAt?: string | null;
27
+ renewAfter?: string | null;
28
+ invalidExpansion?: boolean;
29
+ reconcileIntervalSeconds?: number;
30
+ deliveryConfig?: boolean;
31
+ } = {}
32
+ ) => {
33
+ let contexts: { operation: string; auth: unknown; config: unknown; input: any }[] = [];
34
+ let auth = SlateAuth.create<{ token: string }>()
35
+ .output(z.object({ token: z.string() }))
36
+ .addTokenAuth({
37
+ type: 'auth.token',
38
+ key: 'token',
39
+ name: 'Token',
40
+ inputSchema: z.object({ token: z.string() }),
41
+ getOutput: async ctx => ({ output: ctx.input })
42
+ });
43
+ let spec = SlateSpecification.create({
44
+ key: 'lifecycle',
45
+ name: 'Lifecycle',
46
+ description: 'Trigger lifecycle contract',
47
+ auth,
48
+ config: SlateConfig.create(z.object({ tenant: z.string() }))
49
+ });
50
+ let selectionSchema =
51
+ options.selection === 'required'
52
+ ? z.object({ resource: z.string() })
53
+ : options.selection === 'default'
54
+ ? z.object({ resource: z.string().default('default-resource') })
55
+ : undefined;
56
+ let record = (operation: string, ctx: { auth: unknown; config: unknown; input: any }) => {
57
+ contexts.push({ operation, auth: ctx.auth, config: ctx.config, input: ctx.input });
58
+ };
59
+ let registration = {
60
+ webhookRegistrationIdentifier: 'registration',
61
+ webhookRegistrationPayload: { secret: 'verification' },
62
+ ...(options.expiresAt !== undefined ? { expiresAt: options.expiresAt } : {}),
63
+ ...(options.renewAfter !== undefined ? { renewAfter: options.renewAfter } : {})
64
+ };
65
+ let group = triggerGroup(spec, { key: 'webhook', name: 'Webhook', selectionSchema })
66
+ .webhook({
67
+ reconcileIntervalSeconds: options.reconcileIntervalSeconds,
68
+ deliveryConfig: options.deliveryConfig
69
+ ? {
70
+ userConfigSchema: z.object({ topicName: z.string() }),
71
+ fullConfigSchema: z.object({ topicName: z.string(), audience: z.string() }),
72
+ setup: async ctx => {
73
+ record('setup', ctx);
74
+ return {
75
+ webhookSetupDocument: `Deliver to ${ctx.input.webhookUrl}`,
76
+ partialWebhookRegistrationPayload: {}
77
+ };
78
+ },
79
+ finish: async ctx => {
80
+ record('finish', ctx);
81
+ return {
82
+ webhookRegistrationPayload: {
83
+ ...ctx.input.userWebhookRegistrationPayload,
84
+ audience: ctx.input.webhookUrl
85
+ }
86
+ };
87
+ },
88
+ authenticate: async ctx => {
89
+ record('authenticate', ctx);
90
+ let status = Number(ctx.input.request.headers.get('x-reject'));
91
+ if (status) return { rejected: { status } };
92
+ let body = JSON.parse(await ctx.input.request.text()) as { keys: string[] };
93
+ return { routingKeys: body.keys };
94
+ }
95
+ }
96
+ : undefined,
97
+ autoRegistration: {
98
+ webhookTargetList: async ctx => {
99
+ record('targets', ctx);
100
+ return { targets: [], nextPageToken: null };
101
+ },
102
+ webhookRegister: async ctx => {
103
+ record('register', ctx);
104
+ return registration;
105
+ },
106
+ webhookRenew: options.legacy
107
+ ? undefined
108
+ : async ctx => {
109
+ record('renew', ctx);
110
+ return {
111
+ ...registration,
112
+ webhookRegistrationIdentifier: 'renewed-registration'
113
+ };
114
+ },
115
+ webhookUnregister: async () => {}
116
+ },
117
+ process: async ctx => {
118
+ record('process', ctx);
119
+ return {
120
+ events: [
121
+ {
122
+ payload: { raw: true },
123
+ matchers: [{ tenant: 'tenant' }],
124
+ idempotencyKey: 'raw-key'
125
+ }
126
+ ]
127
+ };
128
+ },
129
+ expandWebhook: options.legacy
130
+ ? undefined
131
+ : async ctx => {
132
+ record('expand', ctx);
133
+ // Malformed output deliberately exercises the runtime contract boundary.
134
+ if (options.invalidExpansion) return { events: [{ payload: [] }] };
135
+ let payload = z
136
+ .object({ empty: z.boolean().optional(), preserve: z.boolean().optional() })
137
+ .nullable()
138
+ .parse(ctx.input.payload);
139
+ if (payload?.empty) return { events: [], updatedState: null };
140
+ if (payload?.preserve) return { events: [] };
141
+ return {
142
+ events: [
143
+ { payload: { kind: 'selected', id: 'one' }, idempotencyKey: 'one' },
144
+ { payload: { kind: 'other', id: 'two' }, idempotencyKey: 'two' },
145
+ { payload: { kind: 'selected', id: 'three' }, idempotencyKey: 'three' }
146
+ ],
147
+ updatedState: { cursor: 'next' }
148
+ };
149
+ }
150
+ })
151
+ .routingMatchers(async () => [{ tenant: 'tenant' }])
152
+ .build();
153
+ let pollBuilder = triggerGroup(spec, { key: 'poll', name: 'Poll', selectionSchema })
154
+ .polling({
155
+ pollEvents: async ctx => {
156
+ record('poll', ctx);
157
+ return { events: [], updatedState: ctx.input.state };
158
+ }
159
+ })
160
+ .routingMatchers(async () => []);
161
+ if (selectionSchema) {
162
+ pollBuilder.selectionOptions({
163
+ resource: {
164
+ list: async ctx => {
165
+ record('options', ctx);
166
+ return {
167
+ options: [{ value: 'a', label: 'Resource A' }],
168
+ nextPageToken: ctx.input.pageToken ? null : 'next'
169
+ };
170
+ }
171
+ }
172
+ });
173
+ }
174
+ let poll = pollBuilder.build();
175
+ let matches = 0;
176
+ let selectedInput = z.object({
177
+ kind: z.string().optional(),
178
+ id: z.string().optional(),
179
+ raw: z.boolean().optional()
180
+ });
181
+ let selected = SlateTrigger.create(spec, { key: 'selected', name: 'Selected' })
182
+ .triggerGroup(group)
183
+ .input(selectedInput)
184
+ .output(z.object({ id: z.string() }))
185
+ .matches(payload => {
186
+ matches++;
187
+ let event = selectedInput.safeParse(payload);
188
+ return event.success && (event.data.kind === 'selected' || event.data.raw === true);
189
+ })
190
+ .map(async ctx => {
191
+ let id = ctx.input.id ?? 'raw';
192
+ return { type: 'selected', id, output: { id } };
193
+ })
194
+ .build();
195
+ let slate = Slate.create({
196
+ spec,
197
+ tools: [],
198
+ triggers: [selected],
199
+ triggerGroups: [group, poll]
200
+ });
201
+ let client = createLocalSlateTestClient({
202
+ slate,
203
+ state: {
204
+ config: { tenant: 'tenant' },
205
+ auth: { authenticationMethodId: 'token', output: { token: 'credential' } }
206
+ }
207
+ });
208
+ return { client, contexts, matches: () => matches };
209
+ };
210
+
211
+ let registrationInput = {
212
+ triggerGroupId: 'webhook',
213
+ webhookTargetIdentifier: 'target',
214
+ webhookTargetPayload: { resource: 'target' },
215
+ webhookUrl: 'https://example.com/webhook'
216
+ };
217
+ let renewalInput = {
218
+ ...registrationInput,
219
+ webhookRegistrationIdentifier: 'registration',
220
+ webhookRegistrationPayload: { secret: 'verification' },
221
+ operationId: 'stable-operation'
222
+ };
223
+ let expansionInput = {
224
+ triggerGroupId: 'webhook',
225
+ payload: { raw: true },
226
+ state: { cursor: 'before' },
227
+ deliveryId: 'delivery',
228
+ webhookRegistrationPayload: { secret: 'verification' }
229
+ };
230
+
231
+ describe('trigger lifecycle protocol helpers', () => {
232
+ it('passes the saved delivery config unchanged and independently from user selection', async () => {
233
+ let { client, contexts } = fixture({ deliveryConfig: true });
234
+ let deliveryConfig = {
235
+ topicName: 'projects/p/topics/t',
236
+ audience: 'https://example.com/delivery',
237
+ nested: { kept: ['as', 'saved'] }
238
+ };
239
+ await registerSlateTriggerGroupWebhook({
240
+ client,
241
+ ...registrationInput,
242
+ operationId: 'initial-registration-operation',
243
+ deliveryConfig
244
+ });
245
+ await renewSlateTriggerGroupWebhook({ client, ...renewalInput, deliveryConfig });
246
+ expect(contexts.map(ctx => [ctx.operation, ctx.input.deliveryConfig])).toEqual([
247
+ ['register', deliveryConfig],
248
+ ['renew', deliveryConfig]
249
+ ]);
250
+ expect(contexts[0]?.input.operationId).toBe('initial-registration-operation');
251
+ await registerSlateTriggerGroupWebhook({
252
+ client,
253
+ ...registrationInput,
254
+ selection: { deliveryConfig }
255
+ });
256
+ expect(contexts.at(-1)?.input.deliveryConfig).toBeUndefined();
257
+ expect(contexts.at(-1)?.input.operationId).toBeUndefined();
258
+ });
259
+
260
+ it('exposes the delivery config of an automatic group and completes it without credentials', async () => {
261
+ let { client, contexts } = fixture({ deliveryConfig: true });
262
+ expect((await client.getTriggerGroup('webhook')).triggerGroup.invocation).toMatchObject({
263
+ registration: {
264
+ mode: 'auto',
265
+ deliveryConfig: {
266
+ userConfigSchema: { type: 'object', required: ['topicName'] },
267
+ fullConfigSchema: { type: 'object', required: ['topicName', 'audience'] }
268
+ }
269
+ }
270
+ });
271
+ client.clearAuth();
272
+ let webhookUrl = 'https://example.com/delivery';
273
+ let setup = await client.setupTriggerGroupWebhookManually({
274
+ triggerGroupId: 'webhook',
275
+ webhookUrl
276
+ });
277
+ expect(setup).toEqual({
278
+ webhookSetupDocument: `Deliver to ${webhookUrl}`,
279
+ partialWebhookRegistrationPayload: {}
280
+ });
281
+ let finished = await client.finishTriggerGroupWebhookManualSetup({
282
+ triggerGroupId: 'webhook',
283
+ webhookUrl,
284
+ partialWebhookRegistrationPayload: setup.partialWebhookRegistrationPayload,
285
+ userWebhookRegistrationPayload: { topicName: 'projects/p/topics/t' }
286
+ });
287
+ expect(finished.webhookRegistrationPayload).toEqual({
288
+ topicName: 'projects/p/topics/t',
289
+ audience: webhookUrl
290
+ });
291
+ expect(contexts.map(ctx => [ctx.operation, ctx.auth, ctx.config])).toEqual([
292
+ ['setup', {}, {}],
293
+ ['finish', {}, {}]
294
+ ]);
295
+ await expect(
296
+ fixture().client.setupTriggerGroupWebhookManually({
297
+ triggerGroupId: 'webhook',
298
+ webhookUrl
299
+ })
300
+ ).rejects.toThrow('does not support manual webhook registration');
301
+ });
302
+
303
+ it('authenticates a delivery through the delivery config without credentials', async () => {
304
+ let { client, contexts } = fixture({ deliveryConfig: true });
305
+ client.clearAuth();
306
+ let deliveryConfig = {
307
+ topicName: 'projects/p/topics/t',
308
+ audience: 'https://example.com/d'
309
+ };
310
+ let delivery = {
311
+ client,
312
+ triggerGroupId: 'webhook',
313
+ url: 'https://example.com/d',
314
+ deliveryConfig
315
+ };
316
+ expect(
317
+ await authenticateSlateTriggerGroupWebhookDelivery({
318
+ ...delivery,
319
+ body: JSON.stringify({ keys: ['resource:one', 'resource:two'] })
320
+ })
321
+ ).toEqual({ routingKeys: ['resource:one', 'resource:two'], rejected: null });
322
+ expect(
323
+ await authenticateSlateTriggerGroupWebhookDelivery({
324
+ ...delivery,
325
+ body: JSON.stringify({ keys: [] })
326
+ })
327
+ ).toEqual({ routingKeys: [], rejected: null });
328
+ expect(
329
+ await authenticateSlateTriggerGroupWebhookDelivery({
330
+ ...delivery,
331
+ headers: { 'x-reject': '503' }
332
+ })
333
+ ).toEqual({ routingKeys: [], rejected: { status: 503 } });
334
+ expect(contexts.map(ctx => [ctx.operation, ctx.auth, ctx.config])).toEqual(
335
+ Array(3).fill(['authenticate', {}, {}])
336
+ );
337
+ expect(contexts.map(ctx => ctx.input.deliveryConfig)).toEqual(
338
+ Array(3).fill(deliveryConfig)
339
+ );
340
+ await expect(
341
+ authenticateSlateTriggerGroupWebhookDelivery({
342
+ ...delivery,
343
+ client: fixture().client,
344
+ body: JSON.stringify({ keys: [] })
345
+ })
346
+ ).rejects.toThrow('does not support webhook delivery authentication');
347
+ });
348
+
349
+ it('advertises schemas and capabilities while preserving old group specifications', async () => {
350
+ let { client } = fixture({ selection: 'default' });
351
+ expect((await client.getTriggerGroup('webhook')).triggerGroup).toMatchObject({
352
+ selectionSchema: {
353
+ type: 'object',
354
+ properties: { resource: { type: 'string', default: 'default-resource' } }
355
+ },
356
+ invocation: { type: 'webhook', supportsExpansion: true, supportsRenewal: true }
357
+ });
358
+ let legacy = fixture({ legacy: true });
359
+ let oldGroup = (await legacy.client.getTriggerGroup('webhook')).triggerGroup;
360
+ expect(oldGroup).not.toHaveProperty('selectionSchema');
361
+ expect(oldGroup.invocation).toEqual({ type: 'webhook', registration: { mode: 'auto' } });
362
+ expect(
363
+ (await client.getTriggerGroup('webhook')).triggerGroup.selectionSchema
364
+ ).not.toHaveProperty('required');
365
+ });
366
+
367
+ it('declares bounded reconciliation and passes scheduled null payloads unchanged', async () => {
368
+ for (let reconcileIntervalSeconds of [
369
+ 0,
370
+ 899,
371
+ 900.5,
372
+ Number.NaN,
373
+ Number.POSITIVE_INFINITY
374
+ ]) {
375
+ expect(() => fixture({ reconcileIntervalSeconds })).toThrow(
376
+ 'integer of at least 900 seconds'
377
+ );
378
+ }
379
+ expect(() => fixture({ legacy: true, reconcileIntervalSeconds: 900 })).toThrow(
380
+ 'requires an expandWebhook handler'
381
+ );
382
+ let { client, contexts } = fixture({ reconcileIntervalSeconds: 900 });
383
+ expect((await client.getTriggerGroup('webhook')).triggerGroup.invocation).toMatchObject({
384
+ reconcileIntervalSeconds: 900,
385
+ supportsExpansion: true
386
+ });
387
+ let result = await expandSlateTriggerGroupWebhook({
388
+ client,
389
+ ...expansionInput,
390
+ payload: null,
391
+ deliveryId: 'scheduled-run'
392
+ });
393
+ expect(result.events).toHaveLength(3);
394
+ expect(contexts[0]?.input).toMatchObject({
395
+ payload: null,
396
+ deliveryId: 'scheduled-run',
397
+ state: expansionInput.state,
398
+ webhookRegistrationPayload: expansionInput.webhookRegistrationPayload
399
+ });
400
+ });
401
+
402
+ it('verifies without credentials and expands zero or many events with connection context', async () => {
403
+ let { client, contexts, matches } = fixture({ selection: 'required' });
404
+ let processed = await processSlateTriggerGroupWebhook({
405
+ client,
406
+ triggerGroupId: 'webhook',
407
+ url: registrationInput.webhookUrl,
408
+ webhookRegistrationPayload: expansionInput.webhookRegistrationPayload
409
+ });
410
+ expect(processed).toMatchObject({
411
+ requiresExpansion: true,
412
+ events: [{ triggerIds: [], idempotencyKey: 'raw-key' }]
413
+ });
414
+ expect(matches()).toBe(0);
415
+ expect(contexts[0]).toMatchObject({ operation: 'process', auth: {}, config: {} });
416
+ let expanded = await expandSlateTriggerGroupWebhook({
417
+ client,
418
+ ...expansionInput,
419
+ selection: { resource: 'chosen' }
420
+ });
421
+ expect(expanded).toMatchObject({
422
+ updatedState: { cursor: 'next' },
423
+ events: [
424
+ { idempotencyKey: 'one', triggerIds: ['selected'] },
425
+ { idempotencyKey: 'two', triggerIds: [] },
426
+ { idempotencyKey: 'three', triggerIds: ['selected'] }
427
+ ]
428
+ });
429
+ expect(contexts[1]).toMatchObject({
430
+ operation: 'expand',
431
+ auth: { token: 'credential' },
432
+ config: { tenant: 'tenant' },
433
+ input: {
434
+ payload: expansionInput.payload,
435
+ state: expansionInput.state,
436
+ deliveryId: expansionInput.deliveryId,
437
+ webhookRegistrationPayload: expansionInput.webhookRegistrationPayload,
438
+ selection: { resource: 'chosen' }
439
+ }
440
+ });
441
+ expect(matches()).toBe(3);
442
+ expect(
443
+ await client.mapTriggerEvent('selected', expanded.events[0]!.payload)
444
+ ).toMatchObject({ output: { id: 'one' } });
445
+ let cleared = await expandSlateTriggerGroupWebhook({
446
+ client,
447
+ ...expansionInput,
448
+ payload: { empty: true },
449
+ selection: { resource: 'chosen' }
450
+ });
451
+ expect(cleared).toMatchObject({ events: [], updatedState: null });
452
+ let preserved = await expandSlateTriggerGroupWebhook({
453
+ client,
454
+ ...expansionInput,
455
+ payload: { preserve: true },
456
+ selection: { resource: 'chosen' }
457
+ });
458
+ expect(preserved.events).toEqual([]);
459
+ expect(preserved).not.toHaveProperty('updatedState');
460
+ });
461
+
462
+ it('validates selection before every affected handler and applies accepted defaults', async () => {
463
+ let { client, contexts } = fixture({ selection: 'required' });
464
+ let calls = [
465
+ () => client.listTriggerGroupWebhookTargets({ triggerGroupId: 'webhook' }),
466
+ () => registerSlateTriggerGroupWebhook({ client, ...registrationInput }),
467
+ () => renewSlateTriggerGroupWebhook({ client, ...renewalInput }),
468
+ () => expandSlateTriggerGroupWebhook({ client, ...expansionInput }),
469
+ () => pollSlateTriggerGroupEvents({ client, triggerGroupId: 'poll' })
470
+ ];
471
+ for (let call of calls) await expect(call()).rejects.toThrow('Invalid selection');
472
+ expect(contexts).toEqual([]);
473
+ let defaults = fixture({ selection: 'default' });
474
+ await defaults.client.listTriggerGroupWebhookTargets({ triggerGroupId: 'webhook' });
475
+ await registerSlateTriggerGroupWebhook({ client: defaults.client, ...registrationInput });
476
+ await renewSlateTriggerGroupWebhook({ client: defaults.client, ...renewalInput });
477
+ await expandSlateTriggerGroupWebhook({ client: defaults.client, ...expansionInput });
478
+ await pollSlateTriggerGroupEvents({ client: defaults.client, triggerGroupId: 'poll' });
479
+ expect(defaults.contexts.map(ctx => ctx.input.selection)).toEqual(
480
+ Array(5).fill({ resource: 'default-resource' })
481
+ );
482
+ await pollSlateTriggerGroupEvents({
483
+ client: defaults.client,
484
+ triggerGroupId: 'poll',
485
+ selection: { resource: 'explicit' }
486
+ });
487
+ expect(defaults.contexts.at(-1)?.input.selection).toEqual({ resource: 'explicit' });
488
+ });
489
+
490
+ it('lists options for a marked selection field from an incomplete selection', async () => {
491
+ let { client, contexts } = fixture({ selection: 'required' });
492
+ let marked = (await client.getTriggerGroup('poll')).triggerGroup.selectionSchema;
493
+ let unmarked = (await client.getTriggerGroup('webhook')).triggerGroup.selectionSchema;
494
+ expect(marked?.properties.resource['x-slates-options']).toBe(true);
495
+ expect(unmarked?.properties.resource).not.toHaveProperty('x-slates-options');
496
+
497
+ let first = await listSlateTriggerGroupSelectionOptions({
498
+ client,
499
+ triggerGroupId: 'poll',
500
+ field: 'resource',
501
+ query: ' res ',
502
+ selection: { other: 'entered' }
503
+ });
504
+ expect(first).toMatchObject({
505
+ options: [{ value: 'a', label: 'Resource A' }],
506
+ nextPageToken: 'next'
507
+ });
508
+ expect(contexts.at(-1)).toMatchObject({
509
+ operation: 'options',
510
+ input: { query: 'res', pageToken: null, selection: { other: 'entered' } }
511
+ });
512
+
513
+ let last = await listSlateTriggerGroupSelectionOptions({
514
+ client,
515
+ triggerGroupId: 'poll',
516
+ field: 'resource',
517
+ pageToken: 'next'
518
+ });
519
+ expect(last.nextPageToken).toBeNull();
520
+ expect(contexts.at(-1)?.input).toMatchObject({ query: null, selection: {} });
521
+
522
+ for (let target of [
523
+ { triggerGroupId: 'poll', field: 'missing' },
524
+ { triggerGroupId: 'poll', field: 'constructor' },
525
+ { triggerGroupId: 'webhook', field: 'resource' }
526
+ ]) {
527
+ await expect(
528
+ listSlateTriggerGroupSelectionOptions({ client, ...target })
529
+ ).rejects.toThrow('has no options');
530
+ }
531
+ });
532
+
533
+ it('keeps groups without selection, expansion, or renewal backward compatible', async () => {
534
+ let { client, contexts } = fixture({ legacy: true });
535
+ await registerSlateTriggerGroupWebhook({ client, ...registrationInput });
536
+ await pollSlateTriggerGroupEvents({ client, triggerGroupId: 'poll' });
537
+ expect(contexts.map(ctx => ctx.input.selection)).toEqual([{}, {}]);
538
+ let processed = await processSlateTriggerGroupWebhook({
539
+ client,
540
+ triggerGroupId: 'webhook',
541
+ url: registrationInput.webhookUrl
542
+ });
543
+ expect(processed.events[0]?.triggerIds).toEqual(['selected']);
544
+ expect(processed).not.toHaveProperty('requiresExpansion');
545
+ await expect(renewSlateTriggerGroupWebhook({ client, ...renewalInput })).rejects.toThrow(
546
+ 'does not support webhook renewal'
547
+ );
548
+ await expect(
549
+ expandSlateTriggerGroupWebhook({ client, ...expansionInput })
550
+ ).rejects.toThrow('does not support webhook expansion');
551
+ });
552
+
553
+ it('requires authenticated context for expansion and renewal', async () => {
554
+ let { client, contexts } = fixture();
555
+ client.clearAuth();
556
+ await expect(
557
+ expandSlateTriggerGroupWebhook({ client, ...expansionInput })
558
+ ).rejects.toMatchObject({
559
+ data: { code: 'request.precondition_failed' }
560
+ });
561
+ await expect(
562
+ renewSlateTriggerGroupWebhook({ client, ...renewalInput })
563
+ ).rejects.toMatchObject({
564
+ data: { code: 'request.precondition_failed' }
565
+ });
566
+ expect(contexts).toEqual([]);
567
+ await processSlateTriggerGroupWebhook({
568
+ client,
569
+ triggerGroupId: 'webhook',
570
+ url: registrationInput.webhookUrl
571
+ });
572
+ expect(contexts[0]).toMatchObject({ operation: 'process', auth: {}, config: {} });
573
+ });
574
+
575
+ it('passes stable renewal identity and validates nullable ISO scheduling metadata', async () => {
576
+ let { client, contexts } = fixture({
577
+ expiresAt: '2026-12-01T12:00:00+01:00',
578
+ renewAfter: null
579
+ });
580
+ let registered = await registerSlateTriggerGroupWebhook({ client, ...registrationInput });
581
+ expect(registered).toMatchObject({
582
+ expiresAt: '2026-12-01T12:00:00+01:00',
583
+ renewAfter: null
584
+ });
585
+ let renewed = await renewSlateTriggerGroupWebhook({
586
+ client,
587
+ ...renewalInput,
588
+ selection: { resource: 'chosen' }
589
+ });
590
+ expect(renewed).toMatchObject({
591
+ webhookRegistrationIdentifier: 'renewed-registration',
592
+ expiresAt: registered.expiresAt,
593
+ renewAfter: null
594
+ });
595
+ expect(contexts.at(-1)).toMatchObject({
596
+ operation: 'renew',
597
+ auth: { token: 'credential' },
598
+ config: { tenant: 'tenant' },
599
+ input: {
600
+ operationId: 'stable-operation',
601
+ webhookRegistrationIdentifier: 'registration',
602
+ webhookTargetIdentifier: 'target',
603
+ selection: { resource: 'chosen' }
604
+ }
605
+ });
606
+ let invalid = fixture({ expiresAt: 'tomorrow' });
607
+ await expect(
608
+ registerSlateTriggerGroupWebhook({ client: invalid.client, ...registrationInput })
609
+ ).rejects.toThrow('invalid result');
610
+ await expect(
611
+ renewSlateTriggerGroupWebhook({ client: invalid.client, ...renewalInput })
612
+ ).rejects.toThrow('invalid result');
613
+ let invalidExpansion = fixture({ invalidExpansion: true });
614
+ await expect(
615
+ expandSlateTriggerGroupWebhook({ client: invalidExpansion.client, ...expansionInput })
616
+ ).rejects.toThrow('invalid result');
617
+ });
618
+ });