@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.
- package/.oxfmtrc.json +9 -0
- package/AGENTS.md +64 -0
- package/README.md +78 -0
- package/index.js +7 -0
- package/lib/Admin.js +737 -0
- package/lib/Caller.js +50 -0
- package/lib/Cron.js +109 -0
- package/lib/Validation.js +39 -0
- package/package.json +44 -0
- package/test/Admin.test.js +470 -0
- package/test/Caller.test.js +42 -0
- package/test/Cron.test.js +151 -0
- package/test/Validation.test.js +34 -0
|
@@ -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
|
+
})
|