dsh-scheduled-message 0.1.1

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,433 @@
1
+ /**
2
+ * Host smoke test: drive `index.js` end to end against in-memory services.
3
+ *
4
+ * Run through `test/host.test.mjs`, which installs the storage-domain remap
5
+ * first. Every assertion is about observable behaviour of the shipped Host
6
+ * half: which routes it owns, what it stores, when it delivers, and what it
7
+ * refuses.
8
+ *
9
+ * @module dsh-scheduled-message/scripts/host-smoke
10
+ */
11
+
12
+ import assert from 'node:assert/strict'
13
+ import { apply } from '../index.js'
14
+ import { createStorageDomainStub } from '../test/support/storage-domain-stub.mjs'
15
+
16
+ const ROUTES = [
17
+ ['/api/dsh-scheduled-message/create', 'POST'],
18
+ ['/api/dsh-scheduled-message/list', 'GET'],
19
+ ['/api/dsh-scheduled-message/cancel', 'POST'],
20
+ ['/api/dsh-scheduled-message/reschedule', 'POST'],
21
+ ['/api/dsh-scheduled-message/send-now', 'POST'],
22
+ ['/api/dsh-scheduled-message/retry', 'POST'],
23
+ ]
24
+
25
+ let assertions = 0
26
+
27
+ function ok(condition, message) {
28
+ assertions += 1
29
+ assert.ok(condition, message)
30
+ }
31
+
32
+ function equal(actual, expected, message) {
33
+ assertions += 1
34
+ assert.deepEqual(actual, expected, message)
35
+ }
36
+
37
+ /** Build one Host context around in-memory services. */
38
+ function createHost(options = {}) {
39
+ const log = []
40
+ const storageDomain = createStorageDomainStub(log)
41
+ const routes = new Map()
42
+ const prompts = []
43
+ const uploads = []
44
+ const staged = new Map()
45
+ const imageBytes = new Map()
46
+ const fileBytes = new Map()
47
+ const state = { timer: null, promptError: null, scheduleTasks: [] }
48
+ let imageSeq = 0
49
+
50
+ const sessionController = {
51
+ async resolveAgent(sessionId) {
52
+ if (sessionId === 'missing-session') return { error: { message: 'session missing' } }
53
+ return { agent: { session: { id: sessionId } } }
54
+ },
55
+ async prompt(request) {
56
+ if (state.promptError !== null) {
57
+ const error = state.promptError
58
+ state.promptError = null
59
+ throw error
60
+ }
61
+ prompts.push(request)
62
+ return { accepted: true }
63
+ },
64
+ }
65
+
66
+ const attachments = {
67
+ async saveImages(inputs) {
68
+ return inputs.map((input) => {
69
+ imageSeq += 1
70
+ const ref = {
71
+ attachmentId: `image-${imageSeq}`,
72
+ mediaType: input.mediaType,
73
+ bytes: input.data.byteLength,
74
+ width: 1,
75
+ height: 1,
76
+ ...(input.name === undefined ? {} : { name: input.name }),
77
+ }
78
+ imageBytes.set(ref.attachmentId, input.data)
79
+ return ref
80
+ })
81
+ },
82
+ async readImage(ref) {
83
+ const data = imageBytes.get(ref.attachmentId)
84
+ if (data === undefined) throw new Error(`missing image ${ref.attachmentId}`)
85
+ return { ref, data }
86
+ },
87
+ async *readFileStream(ref) {
88
+ const data = fileBytes.get(ref.attachmentId)
89
+ if (data === undefined) throw new Error(`missing file ${ref.attachmentId}`)
90
+ yield data
91
+ },
92
+ }
93
+
94
+ const fileUploads = {
95
+ resolve(_agent, receiptId) {
96
+ return staged.get(receiptId)
97
+ },
98
+ async uploadStream({ sessionId, data, name }) {
99
+ const chunks = []
100
+ for await (const chunk of data) chunks.push(chunk)
101
+ const bytes = Buffer.concat(chunks)
102
+ const ref = { attachmentId: `file-${fileBytes.size + 1}`, name: name ?? 'file', bytes: bytes.byteLength }
103
+ fileBytes.set(ref.attachmentId, bytes)
104
+ const receiptId = `out-receipt-${uploads.length + 1}`
105
+ staged.set(receiptId, { type: 'file', attachment: ref })
106
+ uploads.push({ sessionId, receiptId })
107
+ return { receiptId, file: ref }
108
+ },
109
+ }
110
+
111
+ const schedule = {
112
+ async create(request) {
113
+ state.scheduleTasks.push(request)
114
+ return { id: `schedule-${state.scheduleTasks.length}` }
115
+ },
116
+ }
117
+
118
+ const base = {
119
+ storageDomain,
120
+ sessionController,
121
+ attachments,
122
+ fileUploads,
123
+ connection: {
124
+ fetch: {
125
+ register(route) {
126
+ routes.set(route.path, route)
127
+ return async () => {
128
+ routes.delete(route.path)
129
+ }
130
+ },
131
+ },
132
+ },
133
+ timer: {
134
+ timeout(callback, delay) {
135
+ state.timer = { callback, delay }
136
+ return () => {
137
+ if (state.timer?.callback === callback) state.timer = null
138
+ }
139
+ },
140
+ },
141
+ logger: { warn: message => log.push(['warn', String(message)]) },
142
+ ...(options.withSchedule === false ? {} : { schedule }),
143
+ }
144
+
145
+ const ctx = {
146
+ ...base,
147
+ effect(factory) {
148
+ return factory()
149
+ },
150
+ inject(names, callback) {
151
+ const scope = Object.create(ctx)
152
+ scope.inject = (innerNames, innerCallback) => {
153
+ // The Automation service is optional: without it the Host must not run
154
+ // this scope at all, exactly as Cordis waits for a missing service.
155
+ if (innerNames.includes('schedule') && options.withSchedule === false) return undefined
156
+ const inner = Object.create(scope)
157
+ if (innerNames.includes('schedule')) inner.schedule = schedule
158
+ return innerCallback(inner)
159
+ }
160
+ return callback(scope)
161
+ },
162
+ get(name) {
163
+ return ctx[name]
164
+ },
165
+ }
166
+
167
+ return {
168
+ ctx,
169
+ state,
170
+ routes,
171
+ prompts,
172
+ uploads,
173
+ staged,
174
+ storageDomain,
175
+ log,
176
+ /** Stage one inbound file receipt the composer would have uploaded. */
177
+ stageFile(name, text) {
178
+ const ref = { attachmentId: `in-file-${fileBytes.size + 1}`, name, bytes: Buffer.byteLength(text) }
179
+ fileBytes.set(ref.attachmentId, Buffer.from(text))
180
+ const receiptId = `in-receipt-${staged.size + 1}`
181
+ staged.set(receiptId, { type: 'file', attachment: ref })
182
+ return receiptId
183
+ },
184
+ }
185
+ }
186
+
187
+ function tableOf(host) {
188
+ return host.storageDomain.opened[0].table('messages')
189
+ }
190
+
191
+ async function until(predicate, label, timeoutMs = 5000) {
192
+ const start = Date.now()
193
+ for (;;) {
194
+ if (await predicate()) return
195
+ if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${label}`)
196
+ await new Promise(resolve => setTimeout(resolve, 10))
197
+ }
198
+ }
199
+
200
+ function call(route, options = {}) {
201
+ const url = `http://127.0.0.1${route.path}${options.query ?? ''}`
202
+ const init = { method: route.methods[0] }
203
+ if (options.body !== undefined) {
204
+ init.body = JSON.stringify(options.body)
205
+ init.headers = { 'content-type': 'application/json' }
206
+ }
207
+ return route.fetch(new Request(url, init))
208
+ }
209
+
210
+ async function body(route, options) {
211
+ const response = await call(route, options)
212
+ return await response.json()
213
+ }
214
+
215
+ function routeOf(routes, path) {
216
+ const route = routes.get(path)
217
+ assert.ok(route !== undefined, `route ${path} is not registered`)
218
+ return route
219
+ }
220
+
221
+ async function fireWhenArmed(host) {
222
+ await until(() => host.state.timer !== null, 'a timer to be armed')
223
+ const armed = host.state.timer
224
+ host.state.timer = null
225
+ armed.callback()
226
+ }
227
+
228
+ /** Create through the route, then optionally pull the record into the past. */
229
+ async function schedule(host, overrides = {}) {
230
+ const created = await body(routeOf(host.routes, '/api/dsh-scheduled-message/create'), {
231
+ body: {
232
+ sessionId: overrides.sessionId ?? 'session-a',
233
+ dueAt: new Date(Date.now() + 120_000).toISOString(),
234
+ timeZone: overrides.timeZone ?? 'Asia/Hong_Kong',
235
+ text: overrides.text ?? 'text',
236
+ ...(overrides.images === undefined ? {} : { images: overrides.images }),
237
+ ...(overrides.fileReceipts === undefined ? {} : { fileReceipts: overrides.fileReceipts }),
238
+ ...(overrides.repeat === undefined ? {} : { repeat: overrides.repeat }),
239
+ },
240
+ })
241
+ if (created.ok !== true) return { created }
242
+ if (created.value.kind !== 'once') return { created }
243
+ const id = created.value.message.id
244
+ if (overrides.duePast === true || overrides.status !== undefined) {
245
+ const table = tableOf(host)
246
+ const current = table.get(id)
247
+ await table.put(id, {
248
+ ...current,
249
+ ...(overrides.status === undefined ? {} : { status: overrides.status }),
250
+ ...(overrides.duePast === true ? { dueAt: new Date(Date.now() - 1000).toISOString() } : {}),
251
+ })
252
+ }
253
+ return { created, id }
254
+ }
255
+
256
+ // ---------------------------------------------------------------- scenario
257
+
258
+ const host = createHost()
259
+ apply(host.ctx)
260
+
261
+ // 1. Routes.
262
+ equal([...host.routes.keys()].sort(), ROUTES.map(([path]) => path).sort(), 'the Host owns exactly six routes')
263
+ for (const [path, method] of ROUTES) {
264
+ const route = routeOf(host.routes, path)
265
+ ok(route.methods.includes(method), `${path} accepts ${method}`)
266
+ equal(route.requestBody, path.endsWith('/create') ? 'streaming' : 'buffered', `${path} body mode`)
267
+ }
268
+
269
+ await until(() => host.storageDomain.opened.length > 0, 'the durable domain to open')
270
+ const table = tableOf(host)
271
+
272
+ // 2. Rejections before any durable write.
273
+ const createRoute = routeOf(host.routes, '/api/dsh-scheduled-message/create')
274
+ const tooSoon = await body(createRoute, {
275
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 5_000).toISOString(), timeZone: 'Asia/Hong_Kong', text: 'hi' },
276
+ })
277
+ equal(tooSoon.error?.code, 'too-soon', 'a time under thirty seconds ahead is refused')
278
+
279
+ const empty = await body(createRoute, {
280
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 60_000).toISOString(), timeZone: 'Asia/Hong_Kong', text: ' ' },
281
+ })
282
+ equal(empty.error?.code, 'empty', 'an empty message is refused')
283
+
284
+ const badZone = await body(createRoute, {
285
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 60_000).toISOString(), timeZone: 'Mars/Olympus', text: 'hi' },
286
+ })
287
+ equal(badZone.error?.code, 'invalid-time-zone', 'an unknown zone is refused')
288
+
289
+ const unknownSession = await body(createRoute, {
290
+ body: { sessionId: 'missing-session', dueAt: new Date(Date.now() + 60_000).toISOString(), timeZone: 'UTC', text: 'hi' },
291
+ })
292
+ equal(unknownSession.error?.code, 'unknown-session', 'a conversation that cannot be resolved is refused')
293
+
294
+ const repeatWithImage = await body(createRoute, {
295
+ body: {
296
+ sessionId: 'session-a',
297
+ dueAt: new Date(Date.now() + 60_000).toISOString(),
298
+ timeZone: 'UTC',
299
+ text: 'hi',
300
+ repeat: 'daily',
301
+ images: [{ mediaType: 'image/png', data: Buffer.from('png').toString('base64') }],
302
+ },
303
+ })
304
+ equal(repeatWithImage.error?.code, 'repeat-with-attachments', 'Repeat refuses attachments')
305
+
306
+ const unknownReceipt = await body(createRoute, {
307
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 60_000).toISOString(), timeZone: 'UTC', text: 'hi', fileReceipts: ['nope'] },
308
+ })
309
+ equal(unknownReceipt.error?.code, 'unknown-receipt', 'a receipt the Host never staged is refused')
310
+
311
+ equal(table.size, 0, 'no refused request wrote a record')
312
+
313
+ // 3. Create a text-only one-shot.
314
+ const first = await schedule(host, { text: ' hello there ' })
315
+ equal(first.created.ok, true, 'a text-only message is accepted')
316
+ equal(first.created.value.kind, 'once', 'it is a one-shot')
317
+ equal(first.created.value.message.text, 'hello there', 'the stored text is trimmed')
318
+ equal(first.created.value.message.status, 'pending', 'the record starts pending')
319
+ ok(host.state.timer !== null, 'a timer is armed for it')
320
+
321
+ const listed = await body(routeOf(host.routes, '/api/dsh-scheduled-message/list'), { query: '?sessionId=session-a' })
322
+ equal(listed.value.items.length, 1, 'the strip lists the pending record')
323
+ equal(listed.value.items[0].id, first.id, 'the list names the created record')
324
+ const otherSession = await body(routeOf(host.routes, '/api/dsh-scheduled-message/list'), { query: '?sessionId=session-b' })
325
+ equal(otherSession.value.items.length, 0, 'another conversation does not see it')
326
+
327
+ // 4. Delivery.
328
+ await table.put(first.id, { ...table.get(first.id), dueAt: new Date(Date.now() - 1000).toISOString() })
329
+ await fireWhenArmed(host)
330
+ await until(() => table.get(first.id)?.status === 'sent', 'the record to be sent')
331
+ equal(host.prompts.length, 1, 'exactly one prompt was delivered')
332
+ equal(host.prompts[0].requestId, `dsm-${first.id}`, 'the request id is derived from the record id')
333
+ equal(host.prompts[0].mode, 'queue', 'delivery queues behind a running agent instead of steering it')
334
+ equal(host.prompts[0].clientTimeZone, 'Asia/Hong_Kong', 'the captured zone is replayed')
335
+ equal(host.prompts[0].content, [{ type: 'text', text: 'hello there' }], 'the text arrives unchanged')
336
+ equal(table.get(first.id).late, false, 'an on-time delivery is not marked late')
337
+ ok(typeof table.get(first.id).sentAt === 'string', 'the record keeps its send time')
338
+
339
+ // 5. A failed delivery is reported, then retried with the same request id.
340
+ const failing = await schedule(host, { text: 'fail me', duePast: true })
341
+ host.state.promptError = Object.assign(new Error('the agent refused'), { code: 'session/refused' })
342
+ await fireWhenArmed(host)
343
+ await until(() => table.get(failing.id)?.status === 'failed', 'the failure to be stored')
344
+ ok(String(table.get(failing.id).error).includes('session/refused'), 'the diagnostic reaches the record')
345
+
346
+ const retried = await body(routeOf(host.routes, '/api/dsh-scheduled-message/retry'), { body: { id: failing.id } })
347
+ equal(retried.ok, true, 'a failed record can be retried')
348
+ await until(() => table.get(failing.id)?.status === 'sent', 'the retry to settle')
349
+ equal(host.prompts.at(-1).requestId, `dsm-${failing.id}`, 'the retry reuses the record request id')
350
+
351
+ // 6. Send now, then cancel.
352
+ const immediate = await schedule(host, { text: 'right away' })
353
+ const sentNow = await body(routeOf(host.routes, '/api/dsh-scheduled-message/send-now'), { body: { id: immediate.id } })
354
+ equal(sentNow.value.message.status, 'sent', 'Send now delivers immediately')
355
+ equal(host.prompts.at(-1).content[0].text, 'right away', 'Send now carries the stored text')
356
+
357
+ const doomed = await schedule(host, { text: 'never' })
358
+ const cancelled = await body(routeOf(host.routes, '/api/dsh-scheduled-message/cancel'), { body: { id: doomed.id } })
359
+ equal(cancelled.value.message.status, 'cancelled', 'Cancel stops a pending record')
360
+ const afterCancel = await body(routeOf(host.routes, '/api/dsh-scheduled-message/list'), { query: '?sessionId=session-a' })
361
+ ok(!afterCancel.value.items.some(item => item.id === doomed.id), 'a cancelled record leaves the strip')
362
+
363
+ const cancelSettled = await body(routeOf(host.routes, '/api/dsh-scheduled-message/cancel'), { body: { id: immediate.id } })
364
+ equal(cancelSettled.error?.code, 'not-pending', 'a settled record refuses further mutations')
365
+ const cancelUnknown = await body(routeOf(host.routes, '/api/dsh-scheduled-message/cancel'), { body: { id: 'nope' } })
366
+ equal(cancelUnknown.error?.code, 'not-found', 'an unknown record is reported')
367
+
368
+ // 7. Rescheduling moves the stored instant and refuses a stale one.
369
+ const movable = await schedule(host, { text: 'move me' })
370
+ const target = new Date(Date.now() + 3_600_000).toISOString()
371
+ const moved = await body(routeOf(host.routes, '/api/dsh-scheduled-message/reschedule'), { body: { id: movable.id, dueAt: target } })
372
+ equal(moved.value.message.dueAt, target, 'the new instant is stored')
373
+ const staleMove = await body(routeOf(host.routes, '/api/dsh-scheduled-message/reschedule'), {
374
+ body: { id: movable.id, dueAt: new Date(Date.now() + 1000).toISOString() },
375
+ })
376
+ equal(staleMove.error?.code, 'too-soon', 'a new instant under thirty seconds ahead is refused')
377
+ await body(routeOf(host.routes, '/api/dsh-scheduled-message/cancel'), { body: { id: movable.id } })
378
+
379
+ // 8. Crash recovery: a record left in `sending` is delivered at startup.
380
+ const crashed = await schedule(host, { text: 'after crash', status: 'sending', duePast: true })
381
+ await fireWhenArmed(host)
382
+ await until(() => table.get(crashed.id)?.status === 'sent', 'the crash-left record to settle')
383
+ equal(host.prompts.at(-1).requestId, `dsm-${crashed.id}`, 'the recovered delivery keeps its request id')
384
+
385
+ // 9. Images and files survive to delivery.
386
+ const imageBase64 = Buffer.from('image-bytes').toString('base64')
387
+ const fileReceipt = host.stageFile('note.txt', 'file-bytes')
388
+ const attached = await schedule(host, {
389
+ text: 'look',
390
+ images: [{ mediaType: 'image/png', data: imageBase64, name: 'shot.png' }],
391
+ fileReceipts: [fileReceipt],
392
+ duePast: true,
393
+ })
394
+ equal(attached.created.ok, true, 'a message with an image and a file is accepted')
395
+ equal(attached.created.value.message.imageCount, 1, 'the record counts one image')
396
+ equal(attached.created.value.message.fileCount, 1, 'the record counts one file')
397
+ await fireWhenArmed(host)
398
+ await until(() => table.get(attached.id)?.status === 'sent', 'the attachment message to settle')
399
+ const content = host.prompts.at(-1).content
400
+ equal(content[0].text, 'look', 'the text is still first')
401
+ equal(content[1].type, 'image', 'the image part is rebuilt')
402
+ equal(content[1].data, imageBase64, 'the stored image bytes are re-encoded')
403
+ equal(content[1].name, 'shot.png', 'the display name survives')
404
+ equal(content[2].type, 'file', 'the file part is re-staged')
405
+ equal(content[2].receiptId, 'out-receipt-1', 'the file is uploaded again and cited by receipt')
406
+ equal(host.uploads[0].sessionId, 'session-a', 'the file is uploaded against the target session')
407
+
408
+ // 10. Repeat creates an automation task instead of a record.
409
+ const before = table.size
410
+ const automation = await schedule(host, { text: 'stand up', repeat: 'weekdays' })
411
+ equal(automation.created.value.kind, 'automation', 'Repeat answers with an automation task')
412
+ equal(table.size, before, 'Repeat writes no scheduled-message record')
413
+ equal(host.state.scheduleTasks.length, 1, 'one automation task was created')
414
+ const task = host.state.scheduleTasks[0]
415
+ equal(task.title, 'stand up', 'the title summarises the message')
416
+ equal(task.target, { kind: 'session', sessionId: 'session-a' }, 'the task delivers to the same conversation')
417
+ ok(task.timing.rrule.startsWith('DTSTART;TZID=Asia/Hong_Kong:'), 'the rule pins the chosen zone')
418
+ ok(task.timing.rrule.includes('BYDAY=MO,TU,WE,TH,FR'), 'weekdays map onto BYDAY')
419
+
420
+ // 11. Without the Automation service, Repeat is refused instead of crashing.
421
+ const soloHost = createHost({ withSchedule: false })
422
+ apply(soloHost.ctx)
423
+ await until(() => soloHost.storageDomain.opened.length > 0, 'the second Host domain to open')
424
+ const noSchedule = await body(routeOf(soloHost.routes, '/api/dsh-scheduled-message/create'), {
425
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 120_000).toISOString(), timeZone: 'UTC', text: 'x', repeat: 'daily' },
426
+ })
427
+ equal(noSchedule.error?.code, 'repeat-unavailable', 'Repeat reports the missing service')
428
+ const soloOnce = await body(routeOf(soloHost.routes, '/api/dsh-scheduled-message/create'), {
429
+ body: { sessionId: 'session-a', dueAt: new Date(Date.now() + 120_000).toISOString(), timeZone: 'UTC', text: 'still works' },
430
+ })
431
+ equal(soloOnce.value.kind, 'once', 'a one-shot still works without the Automation service')
432
+
433
+ process.stdout.write(`host smoke: ${assertions} assertions passed\n`)