@empyria/restate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,470 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
2
+ import {
3
+ checkServiceHandler,
4
+ startSubWorkflow,
5
+ callSubWorkflow,
6
+ listServices,
7
+ deleteDeployment,
8
+ service,
9
+ listHandlers,
10
+ submitWorkflow,
11
+ submitWorkflowDiscovery,
12
+ waitForInvocation,
13
+ pollInvocation,
14
+ sendMessage,
15
+ sendMessageAsync,
16
+ sendMessageWithDiscovery,
17
+ sendMessageAsyncWithDiscovery,
18
+ registerDeployment,
19
+ sendMessageAsynSafe,
20
+ defineWorkflow,
21
+ defineService,
22
+ defineObject,
23
+ createRestateAdmin,
24
+ restate,
25
+ clients,
26
+ } from '../lib/Admin.js'
27
+
28
+ // Captured once, before anything ever mocks '@restatedev/restate-sdk-clients' — `clients`
29
+ // itself is a live binding, so reading `clients.connect` later could observe a still-active
30
+ // mock instead of the real function.
31
+ const realConnect = clients.connect
32
+
33
+ const jsonResponse = (body, ok = true, status = ok ? 200 : 500) => ({
34
+ ok,
35
+ status,
36
+ statusText: ok ? 'OK' : 'Error',
37
+ json: async () => body,
38
+ text: async () => JSON.stringify(body),
39
+ })
40
+
41
+ let originalFetch
42
+
43
+ beforeEach(() => {
44
+ originalFetch = globalThis.fetch
45
+ })
46
+
47
+ afterEach(() => {
48
+ globalThis.fetch = originalFetch
49
+ })
50
+
51
+ const servicesPayload = {
52
+ services: [
53
+ { name: 'MySvc', handlers: [{ name: 'run', ty: 'Workflow' }] },
54
+ { name: 'OtherSvc', handlers: [{ name: 'ping', ty: 'Shared' }] },
55
+ ],
56
+ }
57
+
58
+ // checkServiceHandler's cache (RESTATE_CACHE) is module-level state shared across every
59
+ // call in the process, with no way to reset it from outside — and a cache hit skips the
60
+ // expectedType check entirely. Each test below uses its own service name so they don't
61
+ // pollute each other's cache entries.
62
+ describe('checkServiceHandler', () => {
63
+ test('resolves true when the service/handler exist', async () => {
64
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload))
65
+ expect(await checkServiceHandler('http://admin', 'MySvc', 'run')).toBe(true)
66
+ })
67
+
68
+ test('caches results — a second call for the same service does not re-fetch', async () => {
69
+ // A fresh service name, in a payload of its own — every successful fetch replaces
70
+ // the whole cache with that response's services, so reusing `servicesPayload` (or
71
+ // any name it lists) here would already be cached by an earlier test.
72
+ const fetchMock = mock(async () =>
73
+ jsonResponse({ services: [{ name: 'CachedSvc', handlers: [{ name: 'ping' }] }] }),
74
+ )
75
+ globalThis.fetch = fetchMock
76
+ await checkServiceHandler('http://admin', 'CachedSvc', 'ping')
77
+ await checkServiceHandler('http://admin', 'CachedSvc', 'ping')
78
+ expect(fetchMock).toHaveBeenCalledTimes(1)
79
+ })
80
+
81
+ test('throws when the service is not registered', async () => {
82
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload))
83
+ await expect(checkServiceHandler('http://admin', 'NopeSvc1', 'run')).rejects.toThrow(
84
+ restate.TerminalError,
85
+ )
86
+ })
87
+
88
+ test('throws when the handler is not found on the service', async () => {
89
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload))
90
+ await expect(checkServiceHandler('http://admin', 'MySvc', 'nope-handler')).rejects.toThrow(
91
+ restate.TerminalError,
92
+ )
93
+ })
94
+
95
+ test('throws when the handler type does not match', async () => {
96
+ globalThis.fetch = mock(async () => ({
97
+ ok: true,
98
+ json: async () => ({
99
+ services: [{ name: 'TypedSvc', handlers: [{ name: 'run', ty: 'Workflow' }] }],
100
+ }),
101
+ }))
102
+ await expect(
103
+ checkServiceHandler('http://admin', 'TypedSvc', 'run', 'Shared'),
104
+ ).rejects.toThrow(restate.TerminalError)
105
+ })
106
+
107
+ test('throws when the /services fetch fails', async () => {
108
+ globalThis.fetch = mock(async () => jsonResponse(null, false))
109
+ await expect(checkServiceHandler('http://admin', 'UnfetchableSvc', 'run')).rejects.toThrow(
110
+ restate.TerminalError,
111
+ )
112
+ })
113
+ })
114
+
115
+ describe('startSubWorkflow / callSubWorkflow', () => {
116
+ test("startSubWorkflow sends via ctx.genericSend to the workflow's run handler", () => {
117
+ let sentArgs
118
+ const ctx = { genericSend: (args) => (sentArgs = args) ?? 'handle' }
119
+ startSubWorkflow(ctx, 'MyWorkflow', 'wf-key-1', { a: 1 })
120
+ expect(sentArgs.service).toBe('MyWorkflow')
121
+ expect(sentArgs.method).toBe('run')
122
+ expect(sentArgs.key).toBe('wf-key-1')
123
+ expect(sentArgs.parameter).toEqual({ a: 1 })
124
+ })
125
+
126
+ test('callSubWorkflow calls via ctx.genericCall and returns its result', async () => {
127
+ let calledArgs
128
+ const ctx = {
129
+ genericCall: async (args) => {
130
+ calledArgs = args
131
+ return { done: true }
132
+ },
133
+ }
134
+ const result = await callSubWorkflow(ctx, 'MyWorkflow', 'wf-key-2', { b: 2 })
135
+ expect(calledArgs.service).toBe('MyWorkflow')
136
+ expect(calledArgs.key).toBe('wf-key-2')
137
+ expect(result).toEqual({ done: true })
138
+ })
139
+ })
140
+
141
+ describe('listServices', () => {
142
+ test('returns the services array', async () => {
143
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload))
144
+ expect(await listServices({ restateAdminURL: 'http://admin' })).toEqual(
145
+ servicesPayload.services,
146
+ )
147
+ })
148
+
149
+ test('throws on a failed fetch', async () => {
150
+ globalThis.fetch = mock(async () => jsonResponse(null, false))
151
+ await expect(listServices({ restateAdminURL: 'http://admin' })).rejects.toThrow(
152
+ restate.TerminalError,
153
+ )
154
+ })
155
+ })
156
+
157
+ describe('deleteDeployment', () => {
158
+ test('resolves on success', async () => {
159
+ globalThis.fetch = mock(async () => jsonResponse({}))
160
+ await expect(deleteDeployment('http://admin', 'dep-1')).resolves.toBeUndefined()
161
+ })
162
+
163
+ test('throws on failure', async () => {
164
+ globalThis.fetch = mock(async () => jsonResponse(null, false))
165
+ await expect(deleteDeployment('http://admin', 'dep-1')).rejects.toThrow(
166
+ restate.TerminalError,
167
+ )
168
+ })
169
+ })
170
+
171
+ describe('service / listHandlers', () => {
172
+ test('service returns the service metadata', async () => {
173
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload.services[0]))
174
+ expect(await service({ restateAdminURL: 'http://admin', name: 'MySvc' })).toEqual(
175
+ servicesPayload.services[0],
176
+ )
177
+ })
178
+
179
+ test('service throws a 404-specific error', async () => {
180
+ globalThis.fetch = mock(async () => jsonResponse(null, false, 404))
181
+ await expect(service({ restateAdminURL: 'http://admin', name: 'Nope' })).rejects.toThrow(
182
+ restate.TerminalError,
183
+ )
184
+ })
185
+
186
+ test('service throws a generic error for non-404 failures', async () => {
187
+ globalThis.fetch = mock(async () => jsonResponse(null, false, 500))
188
+ await expect(service({ restateAdminURL: 'http://admin', name: 'Nope' })).rejects.toThrow(
189
+ restate.TerminalError,
190
+ )
191
+ })
192
+
193
+ test('listHandlers delegates to service and returns its handlers', async () => {
194
+ globalThis.fetch = mock(async () => jsonResponse(servicesPayload.services[0]))
195
+ expect(await listHandlers({ restateAdminURL: 'http://admin', name: 'MySvc' })).toEqual(
196
+ servicesPayload.services[0].handlers,
197
+ )
198
+ })
199
+ })
200
+
201
+ describe('submitWorkflow', () => {
202
+ test('returns the submission on Accepted', async () => {
203
+ globalThis.fetch = mock(async () =>
204
+ jsonResponse({ invocationId: 'inv-1', status: 'Accepted' }),
205
+ )
206
+ const result = await submitWorkflow({
207
+ restateURL: 'http://ingress',
208
+ name: 'Wf',
209
+ payload: {},
210
+ })
211
+ expect(result).toEqual({ invocationId: 'inv-1', status: 'Accepted' })
212
+ })
213
+
214
+ test('throws on an unexpected status', async () => {
215
+ globalThis.fetch = mock(async () =>
216
+ jsonResponse({ invocationId: 'inv-1', status: 'Weird' }),
217
+ )
218
+ await expect(
219
+ submitWorkflow({ restateURL: 'http://ingress', name: 'Wf', payload: {} }),
220
+ ).rejects.toThrow()
221
+ })
222
+
223
+ test('throws when the request fails', async () => {
224
+ globalThis.fetch = mock(async () => jsonResponse(null, false))
225
+ await expect(
226
+ submitWorkflow({ restateURL: 'http://ingress', name: 'Wf', payload: {} }),
227
+ ).rejects.toThrow()
228
+ })
229
+ })
230
+
231
+ describe('submitWorkflowDiscovery', () => {
232
+ test('validates the handler exists before submitting', async () => {
233
+ globalThis.fetch = mock(async (url) => {
234
+ if (String(url).endsWith('/services')) return jsonResponse(servicesPayload)
235
+ return jsonResponse({ invocationId: 'inv-1', status: 'Accepted' })
236
+ })
237
+ const submit = submitWorkflowDiscovery({ restateAdminURL: 'http://admin' })
238
+ const result = await submit({ restateURL: 'http://ingress', name: 'MySvc', payload: {} })
239
+ expect(result.status).toBe('Accepted')
240
+ })
241
+ })
242
+
243
+ describe('waitForInvocation / pollInvocation', () => {
244
+ test('waitForInvocation returns the attach result', async () => {
245
+ globalThis.fetch = mock(async () => jsonResponse({ done: true }))
246
+ expect(
247
+ await waitForInvocation({ restateURL: 'http://ingress', invocationId: 'inv-1' }),
248
+ ).toEqual({ done: true })
249
+ })
250
+
251
+ test('waitForInvocation throws on failure', async () => {
252
+ globalThis.fetch = mock(async () => jsonResponse(null, false))
253
+ await expect(
254
+ waitForInvocation({ restateURL: 'http://ingress', invocationId: 'inv-1' }),
255
+ ).rejects.toThrow()
256
+ })
257
+
258
+ test('pollInvocation retries on 470 then returns the output', async () => {
259
+ let calls = 0
260
+ globalThis.fetch = mock(async () => {
261
+ calls++
262
+ if (calls < 3) return { ok: false, status: 470 }
263
+ return jsonResponse({ result: 42 })
264
+ })
265
+ const result = await pollInvocation({
266
+ restateURL: 'http://ingress',
267
+ invocationId: 'inv-1',
268
+ intervalMs: 1,
269
+ })
270
+ expect(result).toEqual({ result: 42 })
271
+ expect(calls).toBe(3)
272
+ })
273
+
274
+ test('pollInvocation throws on an unexpected status', async () => {
275
+ globalThis.fetch = mock(async () => ({ ok: false, status: 500 }))
276
+ await expect(
277
+ pollInvocation({ restateURL: 'http://ingress', invocationId: 'inv-1', intervalMs: 1 }),
278
+ ).rejects.toThrow()
279
+ })
280
+ })
281
+
282
+ describe('sendMessage / sendMessageAsync', () => {
283
+ test('sendMessage returns the parsed JSON body', async () => {
284
+ globalThis.fetch = mock(async () => ({ ok: true, text: async () => '{"a":1}' }))
285
+ const result = await sendMessage({
286
+ restateURL: 'http://ingress',
287
+ name: 'Svc',
288
+ message: 'do',
289
+ payload: {},
290
+ })
291
+ expect(result).toEqual({ a: 1 })
292
+ })
293
+
294
+ test('sendMessage returns undefined for an empty response body', async () => {
295
+ globalThis.fetch = mock(async () => ({ ok: true, text: async () => '' }))
296
+ const result = await sendMessage({
297
+ restateURL: 'http://ingress',
298
+ name: 'Svc',
299
+ message: 'do',
300
+ payload: {},
301
+ })
302
+ expect(result).toBeUndefined()
303
+ })
304
+
305
+ test('sendMessage throws on failure', async () => {
306
+ globalThis.fetch = mock(async () => ({ ok: false, status: 500, statusText: 'err' }))
307
+ await expect(
308
+ sendMessage({ restateURL: 'http://ingress', name: 'Svc', message: 'do', payload: {} }),
309
+ ).rejects.toThrow()
310
+ })
311
+
312
+ test('sendMessageAsync returns the submission on Accepted', async () => {
313
+ globalThis.fetch = mock(async () =>
314
+ jsonResponse({ invocationId: 'inv-2', status: 'Accepted' }),
315
+ )
316
+ const result = await sendMessageAsync({
317
+ restateURL: 'http://ingress',
318
+ name: 'Svc',
319
+ message: 'do',
320
+ payload: {},
321
+ })
322
+ expect(result.invocationId).toBe('inv-2')
323
+ })
324
+
325
+ test('sendMessageAsync throws on failure', async () => {
326
+ globalThis.fetch = mock(async () => ({ ok: false, status: 500, statusText: 'err' }))
327
+ await expect(
328
+ sendMessageAsync({
329
+ restateURL: 'http://ingress',
330
+ name: 'Svc',
331
+ message: 'do',
332
+ payload: {},
333
+ }),
334
+ ).rejects.toThrow()
335
+ })
336
+
337
+ test('sendMessageAsync throws on an unexpected status', async () => {
338
+ globalThis.fetch = mock(async () =>
339
+ jsonResponse({ invocationId: 'inv-2', status: 'Weird' }),
340
+ )
341
+ await expect(
342
+ sendMessageAsync({
343
+ restateURL: 'http://ingress',
344
+ name: 'Svc',
345
+ message: 'do',
346
+ payload: {},
347
+ }),
348
+ ).rejects.toThrow()
349
+ })
350
+
351
+ test('sendMessageWithDiscovery / sendMessageAsyncWithDiscovery validate first', async () => {
352
+ globalThis.fetch = mock(async (url) => {
353
+ if (String(url).endsWith('/services')) return jsonResponse(servicesPayload)
354
+ return { ok: true, text: async () => '' }
355
+ })
356
+ const send = sendMessageWithDiscovery({ restateAdminURL: 'http://admin' })
357
+ await expect(
358
+ send({ restateURL: 'http://ingress', name: 'MySvc', message: 'run', payload: {} }),
359
+ ).resolves.toBeUndefined()
360
+
361
+ globalThis.fetch = mock(async (url) => {
362
+ if (String(url).endsWith('/services')) return jsonResponse(servicesPayload)
363
+ return jsonResponse({ invocationId: 'x', status: 'Accepted' })
364
+ })
365
+ const sendAsync = sendMessageAsyncWithDiscovery({ restateAdminURL: 'http://admin' })
366
+ const result = await sendAsync({
367
+ restateURL: 'http://ingress',
368
+ name: 'MySvc',
369
+ message: 'run',
370
+ payload: {},
371
+ })
372
+ expect(result.status).toBe('Accepted')
373
+ })
374
+ })
375
+
376
+ describe('registerDeployment', () => {
377
+ test('returns the registration result on success', async () => {
378
+ globalThis.fetch = mock(async () =>
379
+ jsonResponse({ id: 'dep-1', services: [{ name: 'X' }] }),
380
+ )
381
+ const result = await registerDeployment({
382
+ restateAdminURL: 'http://admin',
383
+ serviceURL: 'http://svc',
384
+ })
385
+ expect(result.id).toBe('dep-1')
386
+ })
387
+
388
+ test('throws with the response body on failure', async () => {
389
+ globalThis.fetch = mock(async () => ({
390
+ ok: false,
391
+ status: 400,
392
+ text: async () => 'bad uri',
393
+ }))
394
+ await expect(
395
+ registerDeployment({ restateAdminURL: 'http://admin', serviceURL: 'http://svc' }),
396
+ ).rejects.toThrow(/bad uri/)
397
+ })
398
+ })
399
+
400
+ // `clients` is a live ESM namespace binding (read-only — Object.assign/property
401
+ // reassignment throws), so swapping its `connect` implementation needs mock.module
402
+ // against the underlying package rather than patching the imported namespace object.
403
+ describe('sendMessageAsynSafe', () => {
404
+ afterEach(() => {
405
+ mock.module('@restatedev/restate-sdk-clients', () => ({ ...clients, connect: realConnect }))
406
+ })
407
+
408
+ test('throws a TerminalError when the CallerService does not respond within the timeout', async () => {
409
+ const fakeClient = { dispatch: () => new Promise(() => {}) } // never resolves
410
+ mock.module('@restatedev/restate-sdk-clients', () => ({
411
+ ...clients,
412
+ connect: () => ({ serviceClient: () => fakeClient }),
413
+ }))
414
+
415
+ await expect(
416
+ sendMessageAsynSafe({
417
+ restateURL: 'http://ingress',
418
+ callerParams: { name: 'Svc', handler: 'do', payload: {} },
419
+ timeout: 10,
420
+ }),
421
+ ).rejects.toThrow(restate.TerminalError)
422
+ })
423
+
424
+ test('resolves with the invocation ID when the CallerService responds in time', async () => {
425
+ const fakeClient = { dispatch: async () => 'inv-3' }
426
+ mock.module('@restatedev/restate-sdk-clients', () => ({
427
+ ...clients,
428
+ connect: () => ({ serviceClient: () => fakeClient }),
429
+ }))
430
+
431
+ const result = await sendMessageAsynSafe({
432
+ restateURL: 'http://ingress',
433
+ callerParams: { name: 'Svc', handler: 'do', payload: {} },
434
+ timeout: 1000,
435
+ })
436
+ expect(result).toBe('inv-3')
437
+ })
438
+ })
439
+
440
+ // setupRestate is intentionally not covered here: it binds real HTTP/2 + health-check
441
+ // ports, installs process-wide SIGTERM/SIGINT handlers, and its returned `forceClose`
442
+ // calls `process.exit()` — none of which are safe to exercise in-process in a test run.
443
+ // deleteDeployment/registerDeployment (which it composes) are covered above.
444
+
445
+ describe('defineWorkflow / defineService / defineObject', () => {
446
+ test('are passthroughs to the underlying restate.* definers', () => {
447
+ const wf = defineWorkflow({ name: 'Wf', handlers: { run: async () => {} } })
448
+ expect(wf.name).toBe('Wf')
449
+
450
+ const svc = defineService({ name: 'Svc', handlers: { do: async () => {} } })
451
+ expect(svc.name).toBe('Svc')
452
+
453
+ const obj = defineObject({ name: 'Obj', handlers: { do: async () => {} } })
454
+ expect(obj.name).toBe('Obj')
455
+ })
456
+ })
457
+
458
+ describe('createRestateAdmin', () => {
459
+ test('binds restateAdminURL/restateURL across its methods', async () => {
460
+ globalThis.fetch = mock(async (url) => {
461
+ expect(String(url)).toContain('http://admin')
462
+ return jsonResponse(servicesPayload)
463
+ })
464
+ const admin = createRestateAdmin({
465
+ restateAdminURL: 'http://admin',
466
+ restateURL: 'http://ingress',
467
+ })
468
+ expect(await admin.listServices()).toEqual(servicesPayload.services)
469
+ })
470
+ })
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { CallerServiceDef } from '../lib/Caller.js'
3
+
4
+ describe('CallerServiceDef', () => {
5
+ test('has the expected name and a single-attempt retry policy', () => {
6
+ expect(CallerServiceDef.name).toBe('CallerService')
7
+ expect(CallerServiceDef.options.retryPolicy.maxAttempts).toBe(1)
8
+ })
9
+
10
+ test('dispatch forwards the call to the target handler and returns its invocation ID', async () => {
11
+ const ctx = {
12
+ serviceSendClient: ({ name }) => {
13
+ expect(name).toBe('TargetSvc')
14
+ return {
15
+ doThing: async (payload) => {
16
+ expect(payload).toEqual({ a: 1 })
17
+ return { invocationId: Promise.resolve('inv-123') }
18
+ },
19
+ }
20
+ },
21
+ }
22
+
23
+ const result = await CallerServiceDef.handlers.dispatch(ctx, {
24
+ name: 'TargetSvc',
25
+ handler: 'doThing',
26
+ payload: { a: 1 },
27
+ })
28
+ expect(result).toBe('inv-123')
29
+ })
30
+
31
+ test('throws when the handler is not a function on the target client', async () => {
32
+ const ctx = { serviceSendClient: () => ({}) }
33
+
34
+ await expect(
35
+ CallerServiceDef.handlers.dispatch(ctx, {
36
+ name: 'TargetSvc',
37
+ handler: 'missing',
38
+ payload: {},
39
+ }),
40
+ ).rejects.toThrow(/missing.*is not a function/)
41
+ })
42
+ })
@@ -0,0 +1,151 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { cronJobInitiator, cronJob, setJobStateName, JOB_STATE_NAME } from '../lib/Cron.js'
3
+ import { TerminalError } from '@restatedev/restate-sdk'
4
+
5
+ describe('setJobStateName', () => {
6
+ test('returns the name it was given', () => {
7
+ expect(setJobStateName('principia-job-state')).toBe('principia-job-state')
8
+ })
9
+
10
+ test('defaults to "principia-job-state"', () => {
11
+ expect(JOB_STATE_NAME).toBe('principia-job-state')
12
+ })
13
+ })
14
+
15
+ describe('cronJobInitiator.create', () => {
16
+ test('creates a job on a fresh object instance and reports its next execution time', async () => {
17
+ const ctx = {
18
+ rand: { uuidv4: () => 'job-uuid-1' },
19
+ objectClient: () => ({
20
+ initiate: async () => ({ next_execution_time: 'SOME_TIME' }),
21
+ }),
22
+ }
23
+ const result = await cronJobInitiator.service.create(ctx, { cronExpression: '* * * * *' })
24
+ expect(result).toContain('job-uuid-1')
25
+ expect(result).toContain('SOME_TIME')
26
+ })
27
+ })
28
+
29
+ // Every cronJob handler that schedules a next run needs the same "date/objectSendClient/set"
30
+ // wiring; this factory keeps the mocked context consistent across tests.
31
+ function makeSchedulingCtx({ existing } = {}) {
32
+ const sets = []
33
+ return {
34
+ sets,
35
+ get: async () => existing,
36
+ date: { now: async () => Date.now() },
37
+ key: 'job-1',
38
+ objectSendClient: () => ({
39
+ execute: () => ({ invocationId: Promise.resolve('exec-inv-1') }),
40
+ }),
41
+ set: (key, value) => sets.push([key, value]),
42
+ }
43
+ }
44
+
45
+ describe('cronJob.initiate', () => {
46
+ test('schedules the first execution for a fresh job', async () => {
47
+ const ctx = makeSchedulingCtx()
48
+ const request = { cronExpression: '* * * * *' }
49
+
50
+ const jobState = await cronJob.object.initiate(ctx, request)
51
+
52
+ expect(jobState.request).toEqual(request)
53
+ expect(jobState.next_execution_id).toBe('exec-inv-1')
54
+ expect(ctx.sets).toEqual([[JOB_STATE_NAME, jobState]])
55
+ })
56
+
57
+ test('throws when a job already exists for this ID', async () => {
58
+ const ctx = makeSchedulingCtx({ existing: { request: {} } })
59
+ await expect(cronJob.object.initiate(ctx, { cronExpression: '* * * * *' })).rejects.toThrow(
60
+ TerminalError,
61
+ )
62
+ })
63
+
64
+ test('throws on an invalid cron expression', async () => {
65
+ const ctx = makeSchedulingCtx()
66
+ await expect(
67
+ cronJob.object.initiate(ctx, { cronExpression: 'not-a-cron-expression' }),
68
+ ).rejects.toThrow(TerminalError)
69
+ })
70
+ })
71
+
72
+ describe('cronJob.execute', () => {
73
+ test('sends the job payload and reschedules the next execution', async () => {
74
+ const sent = []
75
+ const ctx = makeSchedulingCtx({
76
+ existing: {
77
+ request: {
78
+ service: 'Svc',
79
+ method: 'do',
80
+ key: 'k',
81
+ payload: { a: 1 },
82
+ cronExpression: '* * * * *',
83
+ },
84
+ },
85
+ })
86
+ ctx.genericSend = (opts) => sent.push(opts)
87
+
88
+ await cronJob.object.execute(ctx)
89
+
90
+ expect(sent).toEqual([
91
+ {
92
+ service: 'Svc',
93
+ method: 'do',
94
+ parameter: { a: 1 },
95
+ key: 'k',
96
+ inputSerde: expect.anything(),
97
+ },
98
+ ])
99
+ // scheduleNextExecution ran again and stored a new state
100
+ expect(ctx.sets.length).toBe(1)
101
+ })
102
+
103
+ test('throws when there is no job state to execute', async () => {
104
+ const ctx = makeSchedulingCtx({ existing: undefined })
105
+ await expect(cronJob.object.execute(ctx)).rejects.toThrow(TerminalError)
106
+ })
107
+ })
108
+
109
+ describe('cronJob.cancel', () => {
110
+ test('cancels the pending execution and clears state', async () => {
111
+ const cancelled = []
112
+ const ctx = {
113
+ get: async () => ({ next_execution_id: 'exec-inv-1' }),
114
+ cancel: (id) => cancelled.push(id),
115
+ clearAll: () => {},
116
+ }
117
+ let cleared = false
118
+ ctx.clearAll = () => {
119
+ cleared = true
120
+ }
121
+
122
+ await cronJob.object.cancel(ctx)
123
+
124
+ expect(cancelled).toEqual(['exec-inv-1'])
125
+ expect(cleared).toBe(true)
126
+ })
127
+
128
+ test('still clears state when there is no pending execution', async () => {
129
+ let cleared = false
130
+ const ctx = {
131
+ get: async () => undefined,
132
+ cancel: () => {
133
+ throw new Error('should not be called')
134
+ },
135
+ clearAll: () => {
136
+ cleared = true
137
+ },
138
+ }
139
+
140
+ await cronJob.object.cancel(ctx)
141
+
142
+ expect(cleared).toBe(true)
143
+ })
144
+ })
145
+
146
+ describe('cronJob.getInfo', () => {
147
+ test('returns the current job state', async () => {
148
+ const ctx = { get: async () => ({ request: {} }) }
149
+ expect(await cronJob.object.getInfo(ctx)).toEqual({ request: {} })
150
+ })
151
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { withValidation } from '../lib/Validation.js'
3
+ import { PrincipiaError } from '@empyria/common'
4
+
5
+ const inputSchema = {
6
+ type: 'object',
7
+ properties: { name: { type: 'string' } },
8
+ required: ['name'],
9
+ }
10
+ const outputSchema = {
11
+ type: 'object',
12
+ properties: { greeting: { type: 'string' } },
13
+ required: ['greeting'],
14
+ }
15
+
16
+ describe('withValidation', () => {
17
+ test('validates input and output, and returns the validated output', async () => {
18
+ const handler = withValidation(inputSchema, outputSchema, async (ctx, input) => ({
19
+ greeting: `Hello, ${input.name}`,
20
+ }))
21
+ const result = await handler({}, { name: 'Bob' })
22
+ expect(result).toEqual({ greeting: 'Hello, Bob' })
23
+ })
24
+
25
+ test('throws when the input does not match the schema', async () => {
26
+ const handler = withValidation(inputSchema, outputSchema, async () => ({ greeting: 'hi' }))
27
+ await expect(handler({}, {})).rejects.toThrow(PrincipiaError)
28
+ })
29
+
30
+ test('throws when the handler output does not match the schema', async () => {
31
+ const handler = withValidation(inputSchema, outputSchema, async () => ({ wrong: true }))
32
+ await expect(handler({}, { name: 'Bob' })).rejects.toThrow(PrincipiaError)
33
+ })
34
+ })