digital-workers 2.1.3 → 2.4.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +33 -21
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts.map +1 -1
- package/dist/agent-comms.js +36 -25
- package/dist/agent-comms.js.map +1 -1
- package/dist/approve.d.ts +40 -8
- package/dist/approve.d.ts.map +1 -1
- package/dist/approve.js +86 -20
- package/dist/approve.js.map +1 -1
- package/dist/ask.d.ts +38 -7
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +85 -25
- package/dist/ask.js.map +1 -1
- package/dist/browse.d.ts +223 -0
- package/dist/browse.d.ts.map +1 -0
- package/dist/browse.js +392 -0
- package/dist/browse.js.map +1 -0
- package/dist/capability-tiers.js +3 -3
- package/dist/capability-tiers.js.map +1 -1
- package/dist/cascade-context.d.ts +28 -28
- package/dist/client.d.ts +162 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +64 -0
- package/dist/client.js.map +1 -0
- package/dist/decide.d.ts +42 -6
- package/dist/decide.d.ts.map +1 -1
- package/dist/decide.js +54 -11
- package/dist/decide.js.map +1 -1
- package/dist/do.d.ts +36 -7
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +82 -39
- package/dist/do.js.map +1 -1
- package/dist/error-escalation.d.ts.map +1 -1
- package/dist/error-escalation.js +38 -38
- package/dist/error-escalation.js.map +1 -1
- package/dist/generate.d.ts +48 -7
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +49 -8
- package/dist/generate.js.map +1 -1
- package/dist/goals.d.ts +10 -9
- package/dist/goals.d.ts.map +1 -1
- package/dist/goals.js +30 -24
- package/dist/goals.js.map +1 -1
- package/dist/image.d.ts +189 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +528 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -2
- package/dist/index.js.map +1 -1
- package/dist/is.d.ts +45 -10
- package/dist/is.d.ts.map +1 -1
- package/dist/is.js +56 -21
- package/dist/is.js.map +1 -1
- package/dist/kpis.d.ts +24 -15
- package/dist/kpis.d.ts.map +1 -1
- package/dist/kpis.js +16 -14
- package/dist/kpis.js.map +1 -1
- package/dist/load-balancing.d.ts.map +1 -1
- package/dist/load-balancing.js +124 -38
- package/dist/load-balancing.js.map +1 -1
- package/dist/logger.d.ts +76 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/notify.d.ts +38 -9
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +72 -17
- package/dist/notify.js.map +1 -1
- package/dist/role.d.ts +5 -4
- package/dist/role.d.ts.map +1 -1
- package/dist/role.js +13 -10
- package/dist/role.js.map +1 -1
- package/dist/runtime.d.ts +310 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +510 -0
- package/dist/runtime.js.map +1 -0
- package/dist/team.d.ts +11 -6
- package/dist/team.d.ts.map +1 -1
- package/dist/team.js +22 -15
- package/dist/team.js.map +1 -1
- package/dist/transports/email.d.ts +318 -0
- package/dist/transports/email.d.ts.map +1 -0
- package/dist/transports/email.js +779 -0
- package/dist/transports/email.js.map +1 -0
- package/dist/transports/slack.d.ts +515 -0
- package/dist/transports/slack.d.ts.map +1 -0
- package/dist/transports/slack.js +844 -0
- package/dist/transports/slack.js.map +1 -0
- package/dist/transports.d.ts.map +1 -1
- package/dist/transports.js +44 -25
- package/dist/transports.js.map +1 -1
- package/dist/types.d.ts +141 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/id.d.ts +19 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +21 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/video.d.ts +203 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +528 -0
- package/dist/video.js.map +1 -0
- package/dist/worker.d.ts +343 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +698 -0
- package/dist/worker.js.map +1 -0
- package/package.json +32 -14
- package/src/actions.ts +39 -30
- package/src/agent-comms.ts +54 -92
- package/src/approve.ts +91 -20
- package/src/ask.ts +99 -25
- package/src/browse.ts +627 -0
- package/src/capability-tiers.ts +5 -5
- package/src/client.ts +221 -0
- package/src/decide.ts +81 -35
- package/src/do.ts +98 -52
- package/src/error-escalation.ts +55 -67
- package/src/generate.ts +52 -18
- package/src/goals.ts +36 -27
- package/src/image.ts +816 -0
- package/src/index.ts +187 -2
- package/src/is.ts +59 -25
- package/src/kpis.ts +41 -36
- package/src/load-balancing.ts +132 -46
- package/src/logger.ts +93 -0
- package/src/notify.ts +78 -17
- package/src/role.ts +30 -20
- package/src/runtime.ts +796 -0
- package/src/team.ts +24 -19
- package/src/transports/email.ts +1160 -0
- package/src/transports/slack.ts +1320 -0
- package/src/transports.ts +58 -43
- package/src/types.ts +174 -46
- package/src/utils/id.ts +21 -0
- package/src/video.ts +906 -0
- package/src/worker.ts +1007 -0
- package/test/approve.test.ts +305 -0
- package/test/ask.test.ts +274 -0
- package/test/browse.test.ts +361 -0
- package/test/decide.test.ts +252 -0
- package/test/do.test.ts +144 -0
- package/test/error-logging.test.ts +357 -0
- package/test/generate.test.ts +319 -0
- package/test/image.test.ts +398 -0
- package/test/is.test.ts +287 -0
- package/test/load-balancing-safety.test.ts +404 -0
- package/test/notify.test.ts +434 -0
- package/test/primitives.test.ts +320 -0
- package/test/runtime-integration.test.ts +892 -0
- package/test/transports/crypto.test.ts +230 -0
- package/test/transports/email.test.ts +866 -0
- package/test/transports/id-generation.test.ts +91 -0
- package/test/transports/slack.test.ts +760 -0
- package/test/type-safety.test.ts +834 -0
- package/test/types.test.ts +60 -2
- package/test/video.test.ts +530 -0
- package/test/worker.test.ts +1433 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +42 -0
- package/wrangler.jsonc +36 -0
- package/LICENSE +0 -21
- package/src/actions.js +0 -436
- package/src/approve.js +0 -234
- package/src/ask.js +0 -226
- package/src/decide.js +0 -244
- package/src/do.js +0 -227
- package/src/generate.js +0 -298
- package/src/goals.js +0 -205
- package/src/index.js +0 -68
- package/src/is.js +0 -317
- package/src/kpis.js +0 -270
- package/src/notify.js +0 -219
- package/src/role.js +0 -110
- package/src/team.js +0 -130
- package/src/transports.js +0 -357
- package/src/types.js +0 -71
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Runtime Integration
|
|
3
|
+
*
|
|
4
|
+
* Tests the HumanRequestProcessor that connects transport adapters
|
|
5
|
+
* to request handling, store management, and callback notification.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import {
|
|
10
|
+
HumanRequestProcessor,
|
|
11
|
+
createHumanRequestProcessor,
|
|
12
|
+
InMemoryRequestStore,
|
|
13
|
+
type HumanRequest,
|
|
14
|
+
type HumanRequestStore,
|
|
15
|
+
type RequestStatus,
|
|
16
|
+
type ProcessorConfig,
|
|
17
|
+
type RequestResult,
|
|
18
|
+
} from '../src/runtime.js'
|
|
19
|
+
import { SlackTransport, createSlackTransport } from '../src/transports/slack.js'
|
|
20
|
+
import {
|
|
21
|
+
EmailTransport,
|
|
22
|
+
createEmailTransportWithProvider,
|
|
23
|
+
type EmailProvider,
|
|
24
|
+
} from '../src/transports/email.js'
|
|
25
|
+
import type { DeliveryResult } from '../src/transports.js'
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Mock Setup
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
// Mock fetch for Slack API
|
|
32
|
+
const mockFetch = vi.fn()
|
|
33
|
+
global.fetch = mockFetch
|
|
34
|
+
|
|
35
|
+
// Mock Slack API responses
|
|
36
|
+
const mockSlackPostMessageResponse = {
|
|
37
|
+
ok: true,
|
|
38
|
+
ts: '1234567890.123456',
|
|
39
|
+
channel: 'C123456',
|
|
40
|
+
message: {
|
|
41
|
+
type: 'message',
|
|
42
|
+
text: 'Test message',
|
|
43
|
+
ts: '1234567890.123456',
|
|
44
|
+
bot_id: 'B123456',
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mock email provider
|
|
49
|
+
function createMockEmailProvider(): EmailProvider {
|
|
50
|
+
return {
|
|
51
|
+
name: 'mock',
|
|
52
|
+
send: vi.fn().mockResolvedValue({
|
|
53
|
+
success: true,
|
|
54
|
+
messageId: 'msg_mock_123',
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// InMemoryRequestStore Tests
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
describe('InMemoryRequestStore', () => {
|
|
64
|
+
let store: InMemoryRequestStore
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
store = new InMemoryRequestStore()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('create', () => {
|
|
71
|
+
it('should create a new request', async () => {
|
|
72
|
+
const request = await store.create({
|
|
73
|
+
type: 'approval',
|
|
74
|
+
target: 'manager@example.com',
|
|
75
|
+
content: 'Approve expense $500',
|
|
76
|
+
requestedBy: { id: 'user_1', name: 'Alice' },
|
|
77
|
+
transport: 'email',
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(request.id).toBeDefined()
|
|
81
|
+
expect(request.status).toBe('pending')
|
|
82
|
+
expect(request.createdAt).toBeInstanceOf(Date)
|
|
83
|
+
expect(request.content).toBe('Approve expense $500')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should generate unique IDs', async () => {
|
|
87
|
+
const req1 = await store.create({
|
|
88
|
+
type: 'approval',
|
|
89
|
+
target: 'a@example.com',
|
|
90
|
+
content: 'Test 1',
|
|
91
|
+
transport: 'email',
|
|
92
|
+
})
|
|
93
|
+
const req2 = await store.create({
|
|
94
|
+
type: 'approval',
|
|
95
|
+
target: 'b@example.com',
|
|
96
|
+
content: 'Test 2',
|
|
97
|
+
transport: 'email',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(req1.id).not.toBe(req2.id)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('get', () => {
|
|
105
|
+
it('should retrieve a request by ID', async () => {
|
|
106
|
+
const created = await store.create({
|
|
107
|
+
type: 'approval',
|
|
108
|
+
target: 'manager@example.com',
|
|
109
|
+
content: 'Test',
|
|
110
|
+
transport: 'slack',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const retrieved = await store.get(created.id)
|
|
114
|
+
|
|
115
|
+
expect(retrieved).toBeDefined()
|
|
116
|
+
expect(retrieved?.id).toBe(created.id)
|
|
117
|
+
expect(retrieved?.content).toBe('Test')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return undefined for non-existent request', async () => {
|
|
121
|
+
const retrieved = await store.get('non_existent_id')
|
|
122
|
+
expect(retrieved).toBeUndefined()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('update', () => {
|
|
127
|
+
it('should update request status', async () => {
|
|
128
|
+
const request = await store.create({
|
|
129
|
+
type: 'approval',
|
|
130
|
+
target: 'manager@example.com',
|
|
131
|
+
content: 'Test',
|
|
132
|
+
transport: 'email',
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const updated = await store.update(request.id, {
|
|
136
|
+
status: 'completed',
|
|
137
|
+
result: { approved: true },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(updated?.status).toBe('completed')
|
|
141
|
+
expect(updated?.result).toEqual({ approved: true })
|
|
142
|
+
expect(updated?.completedAt).toBeInstanceOf(Date)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should return undefined when updating non-existent request', async () => {
|
|
146
|
+
const updated = await store.update('non_existent', { status: 'completed' })
|
|
147
|
+
expect(updated).toBeUndefined()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('findByExternalId', () => {
|
|
152
|
+
it('should find request by external message ID', async () => {
|
|
153
|
+
const request = await store.create({
|
|
154
|
+
type: 'approval',
|
|
155
|
+
target: 'manager@example.com',
|
|
156
|
+
content: 'Test',
|
|
157
|
+
transport: 'slack',
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await store.update(request.id, {
|
|
161
|
+
externalId: 'slack_msg_123',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const found = await store.findByExternalId('slack_msg_123')
|
|
165
|
+
|
|
166
|
+
expect(found).toBeDefined()
|
|
167
|
+
expect(found?.id).toBe(request.id)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should return undefined for non-existent external ID', async () => {
|
|
171
|
+
const found = await store.findByExternalId('non_existent')
|
|
172
|
+
expect(found).toBeUndefined()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('listPending', () => {
|
|
177
|
+
it('should list pending requests', async () => {
|
|
178
|
+
await store.create({
|
|
179
|
+
type: 'approval',
|
|
180
|
+
target: 'a@example.com',
|
|
181
|
+
content: 'Test 1',
|
|
182
|
+
transport: 'email',
|
|
183
|
+
})
|
|
184
|
+
const req2 = await store.create({
|
|
185
|
+
type: 'approval',
|
|
186
|
+
target: 'b@example.com',
|
|
187
|
+
content: 'Test 2',
|
|
188
|
+
transport: 'email',
|
|
189
|
+
})
|
|
190
|
+
await store.update(req2.id, { status: 'completed' })
|
|
191
|
+
|
|
192
|
+
const pending = await store.listPending()
|
|
193
|
+
|
|
194
|
+
expect(pending).toHaveLength(1)
|
|
195
|
+
expect(pending[0].content).toBe('Test 1')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('listExpired', () => {
|
|
200
|
+
it('should list expired requests', async () => {
|
|
201
|
+
const pastDate = new Date(Date.now() - 1000)
|
|
202
|
+
|
|
203
|
+
const request = await store.create({
|
|
204
|
+
type: 'approval',
|
|
205
|
+
target: 'manager@example.com',
|
|
206
|
+
content: 'Test',
|
|
207
|
+
transport: 'email',
|
|
208
|
+
expiresAt: pastDate,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const expired = await store.listExpired()
|
|
212
|
+
|
|
213
|
+
expect(expired).toHaveLength(1)
|
|
214
|
+
expect(expired[0].id).toBe(request.id)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should not include non-expired requests', async () => {
|
|
218
|
+
const futureDate = new Date(Date.now() + 60000)
|
|
219
|
+
|
|
220
|
+
await store.create({
|
|
221
|
+
type: 'approval',
|
|
222
|
+
target: 'manager@example.com',
|
|
223
|
+
content: 'Test',
|
|
224
|
+
transport: 'email',
|
|
225
|
+
expiresAt: futureDate,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const expired = await store.listExpired()
|
|
229
|
+
|
|
230
|
+
expect(expired).toHaveLength(0)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// =============================================================================
|
|
236
|
+
// HumanRequestProcessor Tests
|
|
237
|
+
// =============================================================================
|
|
238
|
+
|
|
239
|
+
describe('HumanRequestProcessor', () => {
|
|
240
|
+
let processor: HumanRequestProcessor
|
|
241
|
+
let store: InMemoryRequestStore
|
|
242
|
+
let slackTransport: SlackTransport
|
|
243
|
+
let emailTransport: EmailTransport
|
|
244
|
+
let mockEmailProvider: EmailProvider
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
vi.clearAllMocks()
|
|
248
|
+
|
|
249
|
+
// Setup mock fetch for Slack
|
|
250
|
+
mockFetch.mockResolvedValue({
|
|
251
|
+
ok: true,
|
|
252
|
+
json: () => Promise.resolve(mockSlackPostMessageResponse),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
store = new InMemoryRequestStore()
|
|
256
|
+
|
|
257
|
+
slackTransport = createSlackTransport({
|
|
258
|
+
botToken: 'xoxb-test-token',
|
|
259
|
+
signingSecret: 'test-secret',
|
|
260
|
+
apiUrl: 'https://slack.test/api',
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
mockEmailProvider = createMockEmailProvider()
|
|
264
|
+
emailTransport = createEmailTransportWithProvider(mockEmailProvider, {
|
|
265
|
+
from: 'approvals@example.com',
|
|
266
|
+
approvalBaseUrl: 'https://app.example.com/approvals',
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
processor = createHumanRequestProcessor({
|
|
270
|
+
store,
|
|
271
|
+
transports: {
|
|
272
|
+
slack: slackTransport,
|
|
273
|
+
email: emailTransport,
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
vi.restoreAllMocks()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('submitRequest', () => {
|
|
283
|
+
it('should submit approval request via Slack transport', async () => {
|
|
284
|
+
const result = await processor.submitRequest({
|
|
285
|
+
type: 'approval',
|
|
286
|
+
target: '#approvals',
|
|
287
|
+
content: 'Approve deployment to production?',
|
|
288
|
+
transport: 'slack',
|
|
289
|
+
context: { version: '2.1.0' },
|
|
290
|
+
requestedBy: { id: 'user_1', name: 'Alice' },
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
expect(result.success).toBe(true)
|
|
294
|
+
expect(result.requestId).toBeDefined()
|
|
295
|
+
expect(result.externalId).toBe('1234567890.123456')
|
|
296
|
+
|
|
297
|
+
// Verify request was stored
|
|
298
|
+
const stored = await store.get(result.requestId!)
|
|
299
|
+
expect(stored).toBeDefined()
|
|
300
|
+
expect(stored?.status).toBe('pending')
|
|
301
|
+
expect(stored?.transport).toBe('slack')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('should submit approval request via Email transport', async () => {
|
|
305
|
+
const result = await processor.submitRequest({
|
|
306
|
+
type: 'approval',
|
|
307
|
+
target: 'manager@example.com',
|
|
308
|
+
content: 'Approve expense $500?',
|
|
309
|
+
transport: 'email',
|
|
310
|
+
context: { amount: 500 },
|
|
311
|
+
requestedBy: { id: 'user_2', name: 'Bob' },
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(result.success).toBe(true)
|
|
315
|
+
expect(result.requestId).toBeDefined()
|
|
316
|
+
|
|
317
|
+
expect(mockEmailProvider.send).toHaveBeenCalled()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should store request with timeout', async () => {
|
|
321
|
+
const timeout = 30000 // 30 seconds
|
|
322
|
+
|
|
323
|
+
const result = await processor.submitRequest({
|
|
324
|
+
type: 'approval',
|
|
325
|
+
target: '#approvals',
|
|
326
|
+
content: 'Time-sensitive approval',
|
|
327
|
+
transport: 'slack',
|
|
328
|
+
timeout,
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const stored = await store.get(result.requestId!)
|
|
332
|
+
expect(stored?.expiresAt).toBeDefined()
|
|
333
|
+
|
|
334
|
+
// Should expire roughly at timeout time
|
|
335
|
+
const expectedExpiry = Date.now() + timeout
|
|
336
|
+
const actualExpiry = stored!.expiresAt!.getTime()
|
|
337
|
+
expect(Math.abs(actualExpiry - expectedExpiry)).toBeLessThan(1000)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('should handle transport failure', async () => {
|
|
341
|
+
mockFetch.mockResolvedValueOnce({
|
|
342
|
+
ok: true,
|
|
343
|
+
json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }),
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const result = await processor.submitRequest({
|
|
347
|
+
type: 'approval',
|
|
348
|
+
target: '#nonexistent',
|
|
349
|
+
content: 'Test',
|
|
350
|
+
transport: 'slack',
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
expect(result.success).toBe(false)
|
|
354
|
+
expect(result.error).toContain('channel_not_found')
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('should reject unsupported transport', async () => {
|
|
358
|
+
const result = await processor.submitRequest({
|
|
359
|
+
type: 'approval',
|
|
360
|
+
target: '+1234567890',
|
|
361
|
+
content: 'Test',
|
|
362
|
+
transport: 'sms' as any, // Not configured
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(result.success).toBe(false)
|
|
366
|
+
expect(result.error).toContain('not configured')
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('handleWebhook (Slack)', () => {
|
|
371
|
+
it('should process approval response from Slack', async () => {
|
|
372
|
+
// First submit a request
|
|
373
|
+
const submitResult = await processor.submitRequest({
|
|
374
|
+
type: 'approval',
|
|
375
|
+
target: '#approvals',
|
|
376
|
+
content: 'Approve deployment?',
|
|
377
|
+
transport: 'slack',
|
|
378
|
+
requestedBy: { id: 'user_1' },
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const requestId = submitResult.requestId!
|
|
382
|
+
|
|
383
|
+
// Mock the webhook payload for approval
|
|
384
|
+
const webhookResult = await processor.handleWebhook('slack', {
|
|
385
|
+
type: 'approval_response',
|
|
386
|
+
requestId,
|
|
387
|
+
approved: true,
|
|
388
|
+
respondedBy: { id: 'U123456', name: 'Manager' },
|
|
389
|
+
notes: 'Looks good!',
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
expect(webhookResult.success).toBe(true)
|
|
393
|
+
expect(webhookResult.requestId).toBe(requestId)
|
|
394
|
+
|
|
395
|
+
// Verify request was updated
|
|
396
|
+
const stored = await store.get(requestId)
|
|
397
|
+
expect(stored?.status).toBe('completed')
|
|
398
|
+
expect(stored?.result?.approved).toBe(true)
|
|
399
|
+
expect(stored?.result?.respondedBy?.id).toBe('U123456')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should process rejection response', async () => {
|
|
403
|
+
const submitResult = await processor.submitRequest({
|
|
404
|
+
type: 'approval',
|
|
405
|
+
target: '#approvals',
|
|
406
|
+
content: 'Approve deployment?',
|
|
407
|
+
transport: 'slack',
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const webhookResult = await processor.handleWebhook('slack', {
|
|
411
|
+
type: 'approval_response',
|
|
412
|
+
requestId: submitResult.requestId!,
|
|
413
|
+
approved: false,
|
|
414
|
+
respondedBy: { id: 'U123456' },
|
|
415
|
+
notes: 'Not ready yet',
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
expect(webhookResult.success).toBe(true)
|
|
419
|
+
|
|
420
|
+
const stored = await store.get(submitResult.requestId!)
|
|
421
|
+
expect(stored?.result?.approved).toBe(false)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('should reject webhook for non-existent request', async () => {
|
|
425
|
+
const webhookResult = await processor.handleWebhook('slack', {
|
|
426
|
+
type: 'approval_response',
|
|
427
|
+
requestId: 'non_existent',
|
|
428
|
+
approved: true,
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
expect(webhookResult.success).toBe(false)
|
|
432
|
+
expect(webhookResult.error).toContain('not found')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('should reject webhook for already completed request', async () => {
|
|
436
|
+
const submitResult = await processor.submitRequest({
|
|
437
|
+
type: 'approval',
|
|
438
|
+
target: '#approvals',
|
|
439
|
+
content: 'Test',
|
|
440
|
+
transport: 'slack',
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Complete the request
|
|
444
|
+
await processor.handleWebhook('slack', {
|
|
445
|
+
type: 'approval_response',
|
|
446
|
+
requestId: submitResult.requestId!,
|
|
447
|
+
approved: true,
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// Try to respond again
|
|
451
|
+
const duplicateResult = await processor.handleWebhook('slack', {
|
|
452
|
+
type: 'approval_response',
|
|
453
|
+
requestId: submitResult.requestId!,
|
|
454
|
+
approved: false,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
expect(duplicateResult.success).toBe(false)
|
|
458
|
+
expect(duplicateResult.error).toContain('already completed')
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe('handleWebhook (Email)', () => {
|
|
463
|
+
it('should process approval response from email reply', async () => {
|
|
464
|
+
const submitResult = await processor.submitRequest({
|
|
465
|
+
type: 'approval',
|
|
466
|
+
target: 'manager@example.com',
|
|
467
|
+
content: 'Approve expense?',
|
|
468
|
+
transport: 'email',
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const webhookResult = await processor.handleWebhook('email', {
|
|
472
|
+
type: 'email_reply',
|
|
473
|
+
requestId: submitResult.requestId!,
|
|
474
|
+
from: 'manager@example.com',
|
|
475
|
+
content: 'APPROVED\n\nGo ahead.',
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
expect(webhookResult.success).toBe(true)
|
|
479
|
+
|
|
480
|
+
const stored = await store.get(submitResult.requestId!)
|
|
481
|
+
expect(stored?.result?.approved).toBe(true)
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe('onComplete callback', () => {
|
|
486
|
+
it('should call onComplete when request is completed', async () => {
|
|
487
|
+
const onComplete = vi.fn()
|
|
488
|
+
|
|
489
|
+
processor = createHumanRequestProcessor({
|
|
490
|
+
store,
|
|
491
|
+
transports: { slack: slackTransport },
|
|
492
|
+
onComplete,
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const submitResult = await processor.submitRequest({
|
|
496
|
+
type: 'approval',
|
|
497
|
+
target: '#approvals',
|
|
498
|
+
content: 'Test',
|
|
499
|
+
transport: 'slack',
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
await processor.handleWebhook('slack', {
|
|
503
|
+
type: 'approval_response',
|
|
504
|
+
requestId: submitResult.requestId!,
|
|
505
|
+
approved: true,
|
|
506
|
+
respondedBy: { id: 'U123456' },
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
expect(onComplete).toHaveBeenCalledTimes(1)
|
|
510
|
+
expect(onComplete).toHaveBeenCalledWith(
|
|
511
|
+
expect.objectContaining({
|
|
512
|
+
requestId: submitResult.requestId,
|
|
513
|
+
result: expect.objectContaining({
|
|
514
|
+
approved: true,
|
|
515
|
+
}),
|
|
516
|
+
})
|
|
517
|
+
)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('should call onComplete with rejection result', async () => {
|
|
521
|
+
const onComplete = vi.fn()
|
|
522
|
+
|
|
523
|
+
processor = createHumanRequestProcessor({
|
|
524
|
+
store,
|
|
525
|
+
transports: { slack: slackTransport },
|
|
526
|
+
onComplete,
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
const submitResult = await processor.submitRequest({
|
|
530
|
+
type: 'approval',
|
|
531
|
+
target: '#approvals',
|
|
532
|
+
content: 'Test',
|
|
533
|
+
transport: 'slack',
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
await processor.handleWebhook('slack', {
|
|
537
|
+
type: 'approval_response',
|
|
538
|
+
requestId: submitResult.requestId!,
|
|
539
|
+
approved: false,
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
expect(onComplete).toHaveBeenCalledWith(
|
|
543
|
+
expect.objectContaining({
|
|
544
|
+
result: expect.objectContaining({
|
|
545
|
+
approved: false,
|
|
546
|
+
}),
|
|
547
|
+
})
|
|
548
|
+
)
|
|
549
|
+
})
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
describe('requestor notification', () => {
|
|
553
|
+
it('should notify requestor when request is approved', async () => {
|
|
554
|
+
processor = createHumanRequestProcessor({
|
|
555
|
+
store,
|
|
556
|
+
transports: { slack: slackTransport },
|
|
557
|
+
notifyRequestor: true,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Clear the initial submit call
|
|
561
|
+
mockFetch.mockClear()
|
|
562
|
+
mockFetch.mockResolvedValue({
|
|
563
|
+
ok: true,
|
|
564
|
+
json: () => Promise.resolve(mockSlackPostMessageResponse),
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
const submitResult = await processor.submitRequest({
|
|
568
|
+
type: 'approval',
|
|
569
|
+
target: '#approvals',
|
|
570
|
+
content: 'Approve deployment?',
|
|
571
|
+
transport: 'slack',
|
|
572
|
+
requestedBy: { id: 'U_REQUESTOR' },
|
|
573
|
+
notifyChannel: '#notifications',
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// Clear submit calls, track webhook completion
|
|
577
|
+
mockFetch.mockClear()
|
|
578
|
+
mockFetch.mockResolvedValue({
|
|
579
|
+
ok: true,
|
|
580
|
+
json: () => Promise.resolve(mockSlackPostMessageResponse),
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
await processor.handleWebhook('slack', {
|
|
584
|
+
type: 'approval_response',
|
|
585
|
+
requestId: submitResult.requestId!,
|
|
586
|
+
approved: true,
|
|
587
|
+
respondedBy: { id: 'U123456', name: 'Manager' },
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
// Should have sent notification to requestor
|
|
591
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
592
|
+
const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]
|
|
593
|
+
const body = JSON.parse(lastCall[1].body)
|
|
594
|
+
expect(body.channel).toBe('notifications')
|
|
595
|
+
expect(body.text).toContain('approved')
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
describe('timeout handling', () => {
|
|
600
|
+
it('should expire timed out requests when checked manually', async () => {
|
|
601
|
+
const onTimeout = vi.fn()
|
|
602
|
+
|
|
603
|
+
// Create a request with an already-expired timestamp
|
|
604
|
+
const expiredRequest = await store.create({
|
|
605
|
+
type: 'approval',
|
|
606
|
+
target: '#approvals',
|
|
607
|
+
content: 'Already expired',
|
|
608
|
+
transport: 'slack',
|
|
609
|
+
expiresAt: new Date(Date.now() - 1000), // Already expired
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
processor = createHumanRequestProcessor({
|
|
613
|
+
store,
|
|
614
|
+
transports: { slack: slackTransport },
|
|
615
|
+
onTimeout,
|
|
616
|
+
checkIntervalMs: 100000, // Very long interval - we'll check manually
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// Manually trigger the timeout check by destroying and checking state
|
|
620
|
+
// The processor checks on interval, but we can verify the store state
|
|
621
|
+
const expired = await store.listExpired()
|
|
622
|
+
expect(expired.length).toBe(1)
|
|
623
|
+
expect(expired[0].id).toBe(expiredRequest.id)
|
|
624
|
+
|
|
625
|
+
processor.destroy()
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('should track expiration time correctly', async () => {
|
|
629
|
+
processor = createHumanRequestProcessor({
|
|
630
|
+
store,
|
|
631
|
+
transports: { slack: slackTransport },
|
|
632
|
+
checkIntervalMs: 100000, // Disable auto-check effectively
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const timeout = 30000
|
|
636
|
+
|
|
637
|
+
const submitResult = await processor.submitRequest({
|
|
638
|
+
type: 'approval',
|
|
639
|
+
target: '#approvals',
|
|
640
|
+
content: 'Time-sensitive',
|
|
641
|
+
transport: 'slack',
|
|
642
|
+
timeout,
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
const stored = await store.get(submitResult.requestId!)
|
|
646
|
+
expect(stored?.expiresAt).toBeDefined()
|
|
647
|
+
|
|
648
|
+
// Should expire roughly at timeout time
|
|
649
|
+
const expectedExpiry = Date.now() + timeout
|
|
650
|
+
const actualExpiry = stored!.expiresAt!.getTime()
|
|
651
|
+
expect(Math.abs(actualExpiry - expectedExpiry)).toBeLessThan(1000)
|
|
652
|
+
|
|
653
|
+
processor.destroy()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('should not list non-expired requests as expired', async () => {
|
|
657
|
+
processor = createHumanRequestProcessor({
|
|
658
|
+
store,
|
|
659
|
+
transports: { slack: slackTransport },
|
|
660
|
+
checkIntervalMs: 100000,
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
await processor.submitRequest({
|
|
664
|
+
type: 'approval',
|
|
665
|
+
target: '#approvals',
|
|
666
|
+
content: 'Not expired yet',
|
|
667
|
+
transport: 'slack',
|
|
668
|
+
timeout: 60000, // 60 seconds from now
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
const expired = await store.listExpired()
|
|
672
|
+
expect(expired.length).toBe(0)
|
|
673
|
+
|
|
674
|
+
processor.destroy()
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('should cleanup interval on destroy', () => {
|
|
678
|
+
const onTimeout = vi.fn()
|
|
679
|
+
|
|
680
|
+
processor = createHumanRequestProcessor({
|
|
681
|
+
store,
|
|
682
|
+
transports: { slack: slackTransport },
|
|
683
|
+
onTimeout,
|
|
684
|
+
checkIntervalMs: 100,
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
// Should not throw when destroyed
|
|
688
|
+
expect(() => processor.destroy()).not.toThrow()
|
|
689
|
+
|
|
690
|
+
// Multiple destroys should be safe
|
|
691
|
+
expect(() => processor.destroy()).not.toThrow()
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe('getRequest', () => {
|
|
696
|
+
it('should retrieve request by ID', async () => {
|
|
697
|
+
const submitResult = await processor.submitRequest({
|
|
698
|
+
type: 'approval',
|
|
699
|
+
target: '#approvals',
|
|
700
|
+
content: 'Test content',
|
|
701
|
+
transport: 'slack',
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const request = await processor.getRequest(submitResult.requestId!)
|
|
705
|
+
|
|
706
|
+
expect(request).toBeDefined()
|
|
707
|
+
expect(request?.content).toBe('Test content')
|
|
708
|
+
})
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
describe('cancelRequest', () => {
|
|
712
|
+
it('should cancel a pending request', async () => {
|
|
713
|
+
const submitResult = await processor.submitRequest({
|
|
714
|
+
type: 'approval',
|
|
715
|
+
target: '#approvals',
|
|
716
|
+
content: 'Test',
|
|
717
|
+
transport: 'slack',
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const cancelResult = await processor.cancelRequest(submitResult.requestId!)
|
|
721
|
+
|
|
722
|
+
expect(cancelResult.success).toBe(true)
|
|
723
|
+
|
|
724
|
+
const stored = await store.get(submitResult.requestId!)
|
|
725
|
+
expect(stored?.status).toBe('cancelled')
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it('should not cancel an already completed request', async () => {
|
|
729
|
+
const submitResult = await processor.submitRequest({
|
|
730
|
+
type: 'approval',
|
|
731
|
+
target: '#approvals',
|
|
732
|
+
content: 'Test',
|
|
733
|
+
transport: 'slack',
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
// Complete the request
|
|
737
|
+
await processor.handleWebhook('slack', {
|
|
738
|
+
type: 'approval_response',
|
|
739
|
+
requestId: submitResult.requestId!,
|
|
740
|
+
approved: true,
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
// Try to cancel
|
|
744
|
+
const cancelResult = await processor.cancelRequest(submitResult.requestId!)
|
|
745
|
+
|
|
746
|
+
expect(cancelResult.success).toBe(false)
|
|
747
|
+
expect(cancelResult.error).toContain('already completed')
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
// =============================================================================
|
|
753
|
+
// Integration Tests
|
|
754
|
+
// =============================================================================
|
|
755
|
+
|
|
756
|
+
describe('Runtime Integration', () => {
|
|
757
|
+
let processor: HumanRequestProcessor
|
|
758
|
+
let store: InMemoryRequestStore
|
|
759
|
+
|
|
760
|
+
beforeEach(() => {
|
|
761
|
+
vi.clearAllMocks()
|
|
762
|
+
|
|
763
|
+
mockFetch.mockResolvedValue({
|
|
764
|
+
ok: true,
|
|
765
|
+
json: () => Promise.resolve(mockSlackPostMessageResponse),
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
store = new InMemoryRequestStore()
|
|
769
|
+
|
|
770
|
+
const slackTransport = createSlackTransport({
|
|
771
|
+
botToken: 'xoxb-test-token',
|
|
772
|
+
signingSecret: 'test-secret',
|
|
773
|
+
apiUrl: 'https://slack.test/api',
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
processor = createHumanRequestProcessor({
|
|
777
|
+
store,
|
|
778
|
+
transports: { slack: slackTransport },
|
|
779
|
+
})
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
it('should handle complete approval workflow', async () => {
|
|
783
|
+
const onComplete = vi.fn()
|
|
784
|
+
|
|
785
|
+
processor = createHumanRequestProcessor({
|
|
786
|
+
store,
|
|
787
|
+
transports: {
|
|
788
|
+
slack: createSlackTransport({
|
|
789
|
+
botToken: 'xoxb-test-token',
|
|
790
|
+
signingSecret: 'test-secret',
|
|
791
|
+
apiUrl: 'https://slack.test/api',
|
|
792
|
+
}),
|
|
793
|
+
},
|
|
794
|
+
onComplete,
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
// 1. Submit approval request
|
|
798
|
+
const submitResult = await processor.submitRequest({
|
|
799
|
+
type: 'approval',
|
|
800
|
+
target: '#deployments',
|
|
801
|
+
content: 'Deploy v2.1.0 to production?',
|
|
802
|
+
transport: 'slack',
|
|
803
|
+
context: {
|
|
804
|
+
version: '2.1.0',
|
|
805
|
+
environment: 'production',
|
|
806
|
+
requestedBy: 'alice@company.com',
|
|
807
|
+
},
|
|
808
|
+
requestedBy: { id: 'U_ALICE', name: 'Alice' },
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
expect(submitResult.success).toBe(true)
|
|
812
|
+
|
|
813
|
+
// 2. Verify request is pending
|
|
814
|
+
let request = await processor.getRequest(submitResult.requestId!)
|
|
815
|
+
expect(request?.status).toBe('pending')
|
|
816
|
+
|
|
817
|
+
// 3. Simulate webhook callback (approval)
|
|
818
|
+
const webhookResult = await processor.handleWebhook('slack', {
|
|
819
|
+
type: 'approval_response',
|
|
820
|
+
requestId: submitResult.requestId!,
|
|
821
|
+
approved: true,
|
|
822
|
+
respondedBy: { id: 'U_BOB', name: 'Bob' },
|
|
823
|
+
notes: 'Looks good, proceed!',
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
expect(webhookResult.success).toBe(true)
|
|
827
|
+
|
|
828
|
+
// 4. Verify request is completed
|
|
829
|
+
request = await processor.getRequest(submitResult.requestId!)
|
|
830
|
+
expect(request?.status).toBe('completed')
|
|
831
|
+
expect(request?.result?.approved).toBe(true)
|
|
832
|
+
expect(request?.result?.respondedBy?.name).toBe('Bob')
|
|
833
|
+
expect(request?.result?.notes).toBe('Looks good, proceed!')
|
|
834
|
+
|
|
835
|
+
// 5. Verify callback was invoked
|
|
836
|
+
expect(onComplete).toHaveBeenCalledWith(
|
|
837
|
+
expect.objectContaining({
|
|
838
|
+
requestId: submitResult.requestId,
|
|
839
|
+
request: expect.objectContaining({
|
|
840
|
+
content: 'Deploy v2.1.0 to production?',
|
|
841
|
+
}),
|
|
842
|
+
result: expect.objectContaining({
|
|
843
|
+
approved: true,
|
|
844
|
+
respondedBy: expect.objectContaining({ name: 'Bob' }),
|
|
845
|
+
}),
|
|
846
|
+
})
|
|
847
|
+
)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('should handle rejection workflow', async () => {
|
|
851
|
+
const onComplete = vi.fn()
|
|
852
|
+
|
|
853
|
+
processor = createHumanRequestProcessor({
|
|
854
|
+
store,
|
|
855
|
+
transports: {
|
|
856
|
+
slack: createSlackTransport({
|
|
857
|
+
botToken: 'xoxb-test-token',
|
|
858
|
+
signingSecret: 'test-secret',
|
|
859
|
+
apiUrl: 'https://slack.test/api',
|
|
860
|
+
}),
|
|
861
|
+
},
|
|
862
|
+
onComplete,
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
const submitResult = await processor.submitRequest({
|
|
866
|
+
type: 'approval',
|
|
867
|
+
target: '#expenses',
|
|
868
|
+
content: 'Approve $10,000 expense?',
|
|
869
|
+
transport: 'slack',
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
await processor.handleWebhook('slack', {
|
|
873
|
+
type: 'approval_response',
|
|
874
|
+
requestId: submitResult.requestId!,
|
|
875
|
+
approved: false,
|
|
876
|
+
respondedBy: { id: 'U_CFO' },
|
|
877
|
+
notes: 'Over budget limit',
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
const request = await processor.getRequest(submitResult.requestId!)
|
|
881
|
+
expect(request?.result?.approved).toBe(false)
|
|
882
|
+
expect(request?.result?.notes).toBe('Over budget limit')
|
|
883
|
+
|
|
884
|
+
expect(onComplete).toHaveBeenCalledWith(
|
|
885
|
+
expect.objectContaining({
|
|
886
|
+
result: expect.objectContaining({
|
|
887
|
+
approved: false,
|
|
888
|
+
}),
|
|
889
|
+
})
|
|
890
|
+
)
|
|
891
|
+
})
|
|
892
|
+
})
|