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,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Email Transport Adapter
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
EmailTransport,
|
|
8
|
+
createEmailTransport,
|
|
9
|
+
createEmailTransportWithProvider,
|
|
10
|
+
createResendProvider,
|
|
11
|
+
generateNotificationEmail,
|
|
12
|
+
generateApprovalEmail,
|
|
13
|
+
parseApprovalReply,
|
|
14
|
+
isApproved,
|
|
15
|
+
isRejected,
|
|
16
|
+
isEmailTransportConfig,
|
|
17
|
+
type EmailProvider,
|
|
18
|
+
type EmailMessage,
|
|
19
|
+
type EmailSendResult,
|
|
20
|
+
type EmailTransportConfig,
|
|
21
|
+
type InboundEmail,
|
|
22
|
+
type ApprovalRequestData,
|
|
23
|
+
} from '../../src/transports/email.js'
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Mock Provider
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
function createMockProvider(overrides?: Partial<EmailProvider>): EmailProvider {
|
|
30
|
+
return {
|
|
31
|
+
name: 'mock',
|
|
32
|
+
send: vi.fn().mockResolvedValue({
|
|
33
|
+
success: true,
|
|
34
|
+
messageId: 'msg_mock_123',
|
|
35
|
+
} as EmailSendResult),
|
|
36
|
+
verify: vi.fn().mockResolvedValue(true),
|
|
37
|
+
...overrides,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// EmailTransport Tests
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
describe('EmailTransport', () => {
|
|
46
|
+
let mockProvider: EmailProvider
|
|
47
|
+
let transport: EmailTransport
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockProvider = createMockProvider()
|
|
51
|
+
transport = createEmailTransportWithProvider(mockProvider, {
|
|
52
|
+
from: 'test@example.com',
|
|
53
|
+
replyTo: 'replies@example.com',
|
|
54
|
+
approvalBaseUrl: 'https://app.example.com/approvals',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('constructor', () => {
|
|
59
|
+
it('should create transport with custom provider', () => {
|
|
60
|
+
expect(transport.getProvider()).toBe(mockProvider)
|
|
61
|
+
expect(transport.getConfig().from).toBe('test@example.com')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should create transport with API key (Resend)', () => {
|
|
65
|
+
const apiKeyTransport = createEmailTransport({
|
|
66
|
+
apiKey: 'test-api-key',
|
|
67
|
+
from: 'notifications@example.com',
|
|
68
|
+
})
|
|
69
|
+
expect(apiKeyTransport.getProvider().name).toBe('resend')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should throw error without apiKey or provider', () => {
|
|
73
|
+
expect(() => {
|
|
74
|
+
new EmailTransport({ transport: 'email' })
|
|
75
|
+
}).toThrow('Email transport requires either apiKey or customProvider')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('sendNotification', () => {
|
|
80
|
+
it('should send notification email', async () => {
|
|
81
|
+
const result = await transport.sendNotification({
|
|
82
|
+
to: 'user@example.com',
|
|
83
|
+
message: 'Test notification',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(true)
|
|
87
|
+
expect(result.transport).toBe('email')
|
|
88
|
+
expect(result.messageId).toBe('msg_mock_123')
|
|
89
|
+
|
|
90
|
+
expect(mockProvider.send).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
to: 'user@example.com',
|
|
93
|
+
from: 'test@example.com',
|
|
94
|
+
replyTo: 'replies@example.com',
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should send to multiple recipients', async () => {
|
|
100
|
+
await transport.sendNotification({
|
|
101
|
+
to: ['user1@example.com', 'user2@example.com'],
|
|
102
|
+
message: 'Team notification',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect(mockProvider.send).toHaveBeenCalledWith(
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
to: ['user1@example.com', 'user2@example.com'],
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should include priority in tags', async () => {
|
|
113
|
+
await transport.sendNotification({
|
|
114
|
+
to: 'user@example.com',
|
|
115
|
+
message: 'Urgent!',
|
|
116
|
+
priority: 'urgent',
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(mockProvider.send).toHaveBeenCalledWith(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
tags: expect.arrayContaining([{ name: 'priority', value: 'urgent' }]),
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should include metadata in email content', async () => {
|
|
127
|
+
await transport.sendNotification({
|
|
128
|
+
to: 'user@example.com',
|
|
129
|
+
message: 'Deployment complete',
|
|
130
|
+
metadata: { version: '2.1.0', environment: 'production' },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const sentEmail = (mockProvider.send as ReturnType<typeof vi.fn>).mock
|
|
134
|
+
.calls[0][0] as EmailMessage
|
|
135
|
+
expect(sentEmail.html).toContain('version')
|
|
136
|
+
expect(sentEmail.html).toContain('2.1.0')
|
|
137
|
+
expect(sentEmail.text).toContain('version: 2.1.0')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should handle send failure', async () => {
|
|
141
|
+
mockProvider.send = vi.fn().mockResolvedValue({
|
|
142
|
+
success: false,
|
|
143
|
+
error: 'Rate limit exceeded',
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const result = await transport.sendNotification({
|
|
147
|
+
to: 'user@example.com',
|
|
148
|
+
message: 'Test',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(result.success).toBe(false)
|
|
152
|
+
expect(result.error).toBe('Rate limit exceeded')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('sendApprovalRequest', () => {
|
|
157
|
+
it('should send approval request email', async () => {
|
|
158
|
+
const result = await transport.sendApprovalRequest({
|
|
159
|
+
to: 'manager@example.com',
|
|
160
|
+
request: 'Expense: $500 for AWS',
|
|
161
|
+
requestId: 'apr_123',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
expect(result.success).toBe(true)
|
|
165
|
+
expect(result.transport).toBe('email')
|
|
166
|
+
expect(result.metadata?.requestId).toBe('apr_123')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should include approval URLs when base URL configured', async () => {
|
|
170
|
+
const result = await transport.sendApprovalRequest({
|
|
171
|
+
to: 'manager@example.com',
|
|
172
|
+
request: 'Deploy v2.0',
|
|
173
|
+
requestId: 'apr_456',
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(result.metadata?.approveUrl).toBe('https://app.example.com/approvals/apr_456/approve')
|
|
177
|
+
expect(result.metadata?.rejectUrl).toBe('https://app.example.com/approvals/apr_456/reject')
|
|
178
|
+
|
|
179
|
+
const sentEmail = (mockProvider.send as ReturnType<typeof vi.fn>).mock
|
|
180
|
+
.calls[0][0] as EmailMessage
|
|
181
|
+
expect(sentEmail.html).toContain('https://app.example.com/approvals/apr_456/approve')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should include context in email', async () => {
|
|
185
|
+
await transport.sendApprovalRequest({
|
|
186
|
+
to: 'manager@example.com',
|
|
187
|
+
request: 'Expense approval',
|
|
188
|
+
requestId: 'apr_789',
|
|
189
|
+
context: { amount: 500, category: 'Infrastructure' },
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const sentEmail = (mockProvider.send as ReturnType<typeof vi.fn>).mock
|
|
193
|
+
.calls[0][0] as EmailMessage
|
|
194
|
+
expect(sentEmail.html).toContain('amount')
|
|
195
|
+
expect(sentEmail.html).toContain('500')
|
|
196
|
+
expect(sentEmail.html).toContain('Infrastructure')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should include expiration date', async () => {
|
|
200
|
+
const expiresAt = new Date('2025-12-31T23:59:59Z')
|
|
201
|
+
|
|
202
|
+
await transport.sendApprovalRequest({
|
|
203
|
+
to: 'manager@example.com',
|
|
204
|
+
request: 'Time-sensitive approval',
|
|
205
|
+
requestId: 'apr_exp',
|
|
206
|
+
expiresAt,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const sentEmail = (mockProvider.send as ReturnType<typeof vi.fn>).mock
|
|
210
|
+
.calls[0][0] as EmailMessage
|
|
211
|
+
expect(sentEmail.html).toContain('expires')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should include request ID header', async () => {
|
|
215
|
+
await transport.sendApprovalRequest({
|
|
216
|
+
to: 'manager@example.com',
|
|
217
|
+
request: 'Test',
|
|
218
|
+
requestId: 'apr_header',
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
expect(mockProvider.send).toHaveBeenCalledWith(
|
|
222
|
+
expect.objectContaining({
|
|
223
|
+
headers: { 'X-Approval-Request-Id': 'apr_header' },
|
|
224
|
+
})
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('parseReply', () => {
|
|
230
|
+
it('should parse APPROVED reply', () => {
|
|
231
|
+
const email: InboundEmail = {
|
|
232
|
+
from: 'manager@example.com',
|
|
233
|
+
to: 'approvals@example.com',
|
|
234
|
+
subject: 'Re: Approval Required',
|
|
235
|
+
text: 'APPROVED\n\nLooks good to me.',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const result = transport.parseReply(email)
|
|
239
|
+
|
|
240
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
241
|
+
expect(result.approved).toBe(true)
|
|
242
|
+
expect(result.from).toBe('manager@example.com')
|
|
243
|
+
expect(result.notes).toBe('Looks good to me.')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should parse REJECTED reply', () => {
|
|
247
|
+
const email: InboundEmail = {
|
|
248
|
+
from: 'manager@example.com',
|
|
249
|
+
to: 'approvals@example.com',
|
|
250
|
+
subject: 'Re: Approval Required',
|
|
251
|
+
text: 'REJECTED\n\nBudget exceeded.',
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const result = transport.parseReply(email)
|
|
255
|
+
|
|
256
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
257
|
+
expect(result.approved).toBe(false)
|
|
258
|
+
expect(result.notes).toBe('Budget exceeded.')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should handle various approval keywords', () => {
|
|
262
|
+
const approvalKeywords = ['APPROVED', 'Approve', 'yes', 'LGTM', 'ok']
|
|
263
|
+
|
|
264
|
+
for (const keyword of approvalKeywords) {
|
|
265
|
+
const email: InboundEmail = {
|
|
266
|
+
from: 'test@example.com',
|
|
267
|
+
to: 'approvals@example.com',
|
|
268
|
+
subject: 'Re: Approval',
|
|
269
|
+
text: keyword,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const result = transport.parseReply(email)
|
|
273
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
274
|
+
expect(result.approved).toBe(true)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should handle various rejection keywords', () => {
|
|
279
|
+
const rejectionKeywords = ['REJECTED', 'Reject', 'no', 'deny', 'decline']
|
|
280
|
+
|
|
281
|
+
for (const keyword of rejectionKeywords) {
|
|
282
|
+
const email: InboundEmail = {
|
|
283
|
+
from: 'test@example.com',
|
|
284
|
+
to: 'approvals@example.com',
|
|
285
|
+
subject: 'Re: Approval',
|
|
286
|
+
text: keyword,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const result = transport.parseReply(email)
|
|
290
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
291
|
+
expect(result.approved).toBe(false)
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should extract request ID from content', () => {
|
|
296
|
+
const email: InboundEmail = {
|
|
297
|
+
from: 'manager@example.com',
|
|
298
|
+
to: 'approvals@example.com',
|
|
299
|
+
subject: 'Re: Approval Required',
|
|
300
|
+
text: 'APPROVED\n\nRequest ID: apr_123',
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const result = transport.parseReply(email)
|
|
304
|
+
expect(result.requestId).toBe('apr_123')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should skip quoted content', () => {
|
|
308
|
+
const email: InboundEmail = {
|
|
309
|
+
from: 'manager@example.com',
|
|
310
|
+
to: 'approvals@example.com',
|
|
311
|
+
subject: 'Re: Approval Required',
|
|
312
|
+
text: 'APPROVED\n\n> Previous message content\n> More quoted text',
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = transport.parseReply(email)
|
|
316
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
317
|
+
expect(result.approved).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should handle HTML-only emails', () => {
|
|
321
|
+
const email: InboundEmail = {
|
|
322
|
+
from: 'manager@example.com',
|
|
323
|
+
to: 'approvals@example.com',
|
|
324
|
+
subject: 'Re: Approval',
|
|
325
|
+
html: '<p>APPROVED</p><p>Great work!</p>',
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = transport.parseReply(email)
|
|
329
|
+
expect(result.isApprovalResponse).toBe(true)
|
|
330
|
+
expect(result.approved).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('should handle non-approval emails', () => {
|
|
334
|
+
const email: InboundEmail = {
|
|
335
|
+
from: 'user@example.com',
|
|
336
|
+
to: 'support@example.com',
|
|
337
|
+
subject: 'Question about the system',
|
|
338
|
+
text: 'I have a question about how approvals work.',
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const result = transport.parseReply(email)
|
|
342
|
+
expect(result.isApprovalResponse).toBe(false)
|
|
343
|
+
expect(result.approved).toBeUndefined()
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('toApprovalResult', () => {
|
|
348
|
+
it('should convert parsed reply to ApprovalResult', () => {
|
|
349
|
+
const reply = transport.parseReply({
|
|
350
|
+
from: 'manager@example.com',
|
|
351
|
+
to: 'approvals@example.com',
|
|
352
|
+
subject: 'Re: Approval',
|
|
353
|
+
text: 'APPROVED\n\nGood to go!',
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const result = transport.toApprovalResult(reply, { id: 'manager', name: 'Manager' })
|
|
357
|
+
|
|
358
|
+
expect(result.approved).toBe(true)
|
|
359
|
+
expect(result.approvedBy?.id).toBe('manager')
|
|
360
|
+
expect(result.notes).toBe('Good to go!')
|
|
361
|
+
expect(result.via).toBe('email')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('should use email from address if no approver provided', () => {
|
|
365
|
+
const reply = transport.parseReply({
|
|
366
|
+
from: 'approver@example.com',
|
|
367
|
+
to: 'approvals@example.com',
|
|
368
|
+
subject: 'Re: Approval',
|
|
369
|
+
text: 'APPROVED',
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const result = transport.toApprovalResult(reply)
|
|
373
|
+
|
|
374
|
+
expect(result.approvedBy?.id).toBe('approver@example.com')
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('createHandler', () => {
|
|
379
|
+
it('should create handler for notification payloads', async () => {
|
|
380
|
+
const handler = transport.createHandler()
|
|
381
|
+
|
|
382
|
+
const result = await handler(
|
|
383
|
+
{
|
|
384
|
+
to: 'user@example.com',
|
|
385
|
+
body: 'Test notification',
|
|
386
|
+
type: 'notification',
|
|
387
|
+
},
|
|
388
|
+
{ transport: 'email' }
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
expect(result.success).toBe(true)
|
|
392
|
+
expect(mockProvider.send).toHaveBeenCalled()
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('should create handler for approval payloads', async () => {
|
|
396
|
+
const handler = transport.createHandler()
|
|
397
|
+
|
|
398
|
+
const result = await handler(
|
|
399
|
+
{
|
|
400
|
+
to: 'manager@example.com',
|
|
401
|
+
body: 'Approve this request',
|
|
402
|
+
type: 'approval',
|
|
403
|
+
metadata: { requestId: 'apr_handler' },
|
|
404
|
+
},
|
|
405
|
+
{ transport: 'email' }
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
expect(result.success).toBe(true)
|
|
409
|
+
expect(result.metadata?.requestId).toBe('apr_handler')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('should generate request ID if not provided', async () => {
|
|
413
|
+
const handler = transport.createHandler()
|
|
414
|
+
|
|
415
|
+
const result = await handler(
|
|
416
|
+
{
|
|
417
|
+
to: 'manager@example.com',
|
|
418
|
+
body: 'Approve this',
|
|
419
|
+
type: 'approval',
|
|
420
|
+
},
|
|
421
|
+
{ transport: 'email' }
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
expect(result.metadata?.requestId).toMatch(/^apr_/)
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('register', () => {
|
|
429
|
+
it('should register transport handler', () => {
|
|
430
|
+
// This just verifies the method exists and doesn't throw
|
|
431
|
+
expect(() => transport.register()).not.toThrow()
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// =============================================================================
|
|
437
|
+
// Email Template Tests
|
|
438
|
+
// =============================================================================
|
|
439
|
+
|
|
440
|
+
describe('Email Templates', () => {
|
|
441
|
+
describe('generateNotificationEmail', () => {
|
|
442
|
+
it('should generate notification email', () => {
|
|
443
|
+
const { subject, html, text } = generateNotificationEmail('Test message')
|
|
444
|
+
|
|
445
|
+
expect(subject).toContain('Notification')
|
|
446
|
+
expect(html).toContain('Test message')
|
|
447
|
+
expect(text).toContain('Test message')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('should include priority badge for urgent', () => {
|
|
451
|
+
const { subject, html } = generateNotificationEmail('Urgent!', { priority: 'urgent' })
|
|
452
|
+
|
|
453
|
+
expect(subject).toContain('URGENT')
|
|
454
|
+
expect(html).toContain('URGENT')
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('should include metadata in context section', () => {
|
|
458
|
+
const { html, text } = generateNotificationEmail('Deployment complete', {
|
|
459
|
+
metadata: { version: '2.0', env: 'production' },
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
expect(html).toContain('version')
|
|
463
|
+
expect(html).toContain('2.0')
|
|
464
|
+
expect(text).toContain('version: 2.0')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('should use custom brand name', () => {
|
|
468
|
+
const { subject } = generateNotificationEmail('Test', {
|
|
469
|
+
templates: { brandName: 'Acme Corp' },
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
expect(subject).toContain('Acme Corp')
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('should escape HTML in message', () => {
|
|
476
|
+
const { html } = generateNotificationEmail('<script>alert("xss")</script>')
|
|
477
|
+
|
|
478
|
+
expect(html).not.toContain('<script>')
|
|
479
|
+
expect(html).toContain('<script>')
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
describe('generateApprovalEmail', () => {
|
|
484
|
+
it('should generate approval email', () => {
|
|
485
|
+
const requestData: ApprovalRequestData = {
|
|
486
|
+
requestId: 'apr_123',
|
|
487
|
+
request: 'Test approval',
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { subject, html, text } = generateApprovalEmail('Approve this', requestData)
|
|
491
|
+
|
|
492
|
+
expect(subject).toContain('Approval Required')
|
|
493
|
+
expect(html).toContain('Approve this')
|
|
494
|
+
expect(text).toContain('APPROVED')
|
|
495
|
+
expect(text).toContain('REJECTED')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('should include approval buttons when URLs provided', () => {
|
|
499
|
+
const requestData: ApprovalRequestData = {
|
|
500
|
+
requestId: 'apr_123',
|
|
501
|
+
request: 'Test',
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const { html } = generateApprovalEmail('Test', requestData, {
|
|
505
|
+
approveUrl: 'https://example.com/approve',
|
|
506
|
+
rejectUrl: 'https://example.com/reject',
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
expect(html).toContain('https://example.com/approve')
|
|
510
|
+
expect(html).toContain('https://example.com/reject')
|
|
511
|
+
expect(html).toContain('btn-primary')
|
|
512
|
+
expect(html).toContain('btn-danger')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('should include context in email', () => {
|
|
516
|
+
const requestData: ApprovalRequestData = {
|
|
517
|
+
requestId: 'apr_123',
|
|
518
|
+
request: 'Expense',
|
|
519
|
+
context: { amount: 500, category: 'Travel' },
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { html, text } = generateApprovalEmail('Expense approval', requestData)
|
|
523
|
+
|
|
524
|
+
expect(html).toContain('amount')
|
|
525
|
+
expect(html).toContain('500')
|
|
526
|
+
expect(text).toContain('amount: 500')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('should include expiration notice', () => {
|
|
530
|
+
const requestData: ApprovalRequestData = {
|
|
531
|
+
requestId: 'apr_123',
|
|
532
|
+
request: 'Test',
|
|
533
|
+
expiresAt: Date.now() + 86400000,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const { html } = generateApprovalEmail('Test', requestData)
|
|
537
|
+
|
|
538
|
+
expect(html).toContain('expires')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('should include request ID in footer', () => {
|
|
542
|
+
const requestData: ApprovalRequestData = {
|
|
543
|
+
requestId: 'apr_test_id',
|
|
544
|
+
request: 'Test',
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const { html, text } = generateApprovalEmail('Test', requestData)
|
|
548
|
+
|
|
549
|
+
expect(html).toContain('apr_test_id')
|
|
550
|
+
expect(text).toContain('apr_test_id')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('should include reply instructions', () => {
|
|
554
|
+
const requestData: ApprovalRequestData = {
|
|
555
|
+
requestId: 'apr_123',
|
|
556
|
+
request: 'Test',
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { html, text } = generateApprovalEmail('Test', requestData)
|
|
560
|
+
|
|
561
|
+
expect(html).toContain('Reply via Email')
|
|
562
|
+
expect(text).toContain('reply to this email')
|
|
563
|
+
})
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// =============================================================================
|
|
568
|
+
// Resend Provider Tests
|
|
569
|
+
// =============================================================================
|
|
570
|
+
|
|
571
|
+
describe('createResendProvider', () => {
|
|
572
|
+
beforeEach(() => {
|
|
573
|
+
vi.resetAllMocks()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('should create provider with name "resend"', () => {
|
|
577
|
+
const provider = createResendProvider({ apiKey: 'test-key' })
|
|
578
|
+
expect(provider.name).toBe('resend')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('should send email via Resend API', async () => {
|
|
582
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
583
|
+
ok: true,
|
|
584
|
+
json: () => Promise.resolve({ id: 'resend_msg_123' }),
|
|
585
|
+
})
|
|
586
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
587
|
+
|
|
588
|
+
const provider = createResendProvider({ apiKey: 'test-api-key' })
|
|
589
|
+
const result = await provider.send({
|
|
590
|
+
to: 'user@example.com',
|
|
591
|
+
from: 'sender@example.com',
|
|
592
|
+
subject: 'Test',
|
|
593
|
+
text: 'Hello',
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
expect(result.success).toBe(true)
|
|
597
|
+
expect(result.messageId).toBe('resend_msg_123')
|
|
598
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
599
|
+
'https://api.resend.com/emails',
|
|
600
|
+
expect.objectContaining({
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: expect.objectContaining({
|
|
603
|
+
Authorization: 'Bearer test-api-key',
|
|
604
|
+
}),
|
|
605
|
+
})
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
vi.unstubAllGlobals()
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('should handle API errors', async () => {
|
|
612
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
613
|
+
ok: false,
|
|
614
|
+
statusText: 'Unauthorized',
|
|
615
|
+
json: () => Promise.resolve({ message: 'Invalid API key' }),
|
|
616
|
+
})
|
|
617
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
618
|
+
|
|
619
|
+
const provider = createResendProvider({ apiKey: 'invalid-key' })
|
|
620
|
+
const result = await provider.send({
|
|
621
|
+
to: 'user@example.com',
|
|
622
|
+
from: 'sender@example.com',
|
|
623
|
+
subject: 'Test',
|
|
624
|
+
text: 'Hello',
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
expect(result.success).toBe(false)
|
|
628
|
+
expect(result.error).toBe('Invalid API key')
|
|
629
|
+
|
|
630
|
+
vi.unstubAllGlobals()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('should handle network errors', async () => {
|
|
634
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
635
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
636
|
+
|
|
637
|
+
const provider = createResendProvider({ apiKey: 'test-key' })
|
|
638
|
+
const result = await provider.send({
|
|
639
|
+
to: 'user@example.com',
|
|
640
|
+
from: 'sender@example.com',
|
|
641
|
+
subject: 'Test',
|
|
642
|
+
text: 'Hello',
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
expect(result.success).toBe(false)
|
|
646
|
+
expect(result.error).toBe('Network error')
|
|
647
|
+
|
|
648
|
+
vi.unstubAllGlobals()
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('should use custom API URL', async () => {
|
|
652
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
653
|
+
ok: true,
|
|
654
|
+
json: () => Promise.resolve({ id: 'msg_123' }),
|
|
655
|
+
})
|
|
656
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
657
|
+
|
|
658
|
+
const provider = createResendProvider({
|
|
659
|
+
apiKey: 'test-key',
|
|
660
|
+
apiUrl: 'https://custom.api.com',
|
|
661
|
+
})
|
|
662
|
+
await provider.send({
|
|
663
|
+
to: 'user@example.com',
|
|
664
|
+
from: 'sender@example.com',
|
|
665
|
+
subject: 'Test',
|
|
666
|
+
text: 'Hello',
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
expect(mockFetch).toHaveBeenCalledWith('https://custom.api.com/emails', expect.any(Object))
|
|
670
|
+
|
|
671
|
+
vi.unstubAllGlobals()
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('should verify provider configuration', async () => {
|
|
675
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true })
|
|
676
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
677
|
+
|
|
678
|
+
const provider = createResendProvider({ apiKey: 'test-key' })
|
|
679
|
+
const isValid = await provider.verify!()
|
|
680
|
+
|
|
681
|
+
expect(isValid).toBe(true)
|
|
682
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
683
|
+
'https://api.resend.com/domains',
|
|
684
|
+
expect.objectContaining({
|
|
685
|
+
headers: expect.objectContaining({
|
|
686
|
+
Authorization: 'Bearer test-key',
|
|
687
|
+
}),
|
|
688
|
+
})
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
vi.unstubAllGlobals()
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// =============================================================================
|
|
696
|
+
// Type Guard Tests
|
|
697
|
+
// =============================================================================
|
|
698
|
+
|
|
699
|
+
describe('Type Guards', () => {
|
|
700
|
+
describe('isEmailTransportConfig', () => {
|
|
701
|
+
it('should return true for valid email config', () => {
|
|
702
|
+
const config: EmailTransportConfig = {
|
|
703
|
+
transport: 'email',
|
|
704
|
+
apiKey: 'test',
|
|
705
|
+
}
|
|
706
|
+
expect(isEmailTransportConfig(config)).toBe(true)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('should return false for other transport types', () => {
|
|
710
|
+
expect(isEmailTransportConfig({ transport: 'slack' })).toBe(false)
|
|
711
|
+
expect(isEmailTransportConfig({ transport: 'sms' })).toBe(false)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it('should return false for non-objects', () => {
|
|
715
|
+
expect(isEmailTransportConfig(null)).toBe(false)
|
|
716
|
+
expect(isEmailTransportConfig('email')).toBe(false)
|
|
717
|
+
expect(isEmailTransportConfig(undefined)).toBe(false)
|
|
718
|
+
})
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
describe('isApproved / isRejected', () => {
|
|
722
|
+
it('should identify approved replies', () => {
|
|
723
|
+
const reply = parseApprovalReply({
|
|
724
|
+
from: 'test@example.com',
|
|
725
|
+
to: 'approvals@example.com',
|
|
726
|
+
subject: 'Re: Approval',
|
|
727
|
+
text: 'APPROVED',
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
expect(isApproved(reply)).toBe(true)
|
|
731
|
+
expect(isRejected(reply)).toBe(false)
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it('should identify rejected replies', () => {
|
|
735
|
+
const reply = parseApprovalReply({
|
|
736
|
+
from: 'test@example.com',
|
|
737
|
+
to: 'approvals@example.com',
|
|
738
|
+
subject: 'Re: Approval',
|
|
739
|
+
text: 'REJECTED',
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
expect(isApproved(reply)).toBe(false)
|
|
743
|
+
expect(isRejected(reply)).toBe(true)
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('should return false for non-approval replies', () => {
|
|
747
|
+
const reply = parseApprovalReply({
|
|
748
|
+
from: 'test@example.com',
|
|
749
|
+
to: 'support@example.com',
|
|
750
|
+
subject: 'General question',
|
|
751
|
+
text: 'How does this work?',
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
expect(isApproved(reply)).toBe(false)
|
|
755
|
+
expect(isRejected(reply)).toBe(false)
|
|
756
|
+
})
|
|
757
|
+
})
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// =============================================================================
|
|
761
|
+
// Factory Function Tests
|
|
762
|
+
// =============================================================================
|
|
763
|
+
|
|
764
|
+
describe('Factory Functions', () => {
|
|
765
|
+
describe('createEmailTransport', () => {
|
|
766
|
+
it('should create transport with config', () => {
|
|
767
|
+
const transport = createEmailTransport({
|
|
768
|
+
apiKey: 'test-key',
|
|
769
|
+
from: 'test@example.com',
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
expect(transport).toBeInstanceOf(EmailTransport)
|
|
773
|
+
expect(transport.getConfig().from).toBe('test@example.com')
|
|
774
|
+
})
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
describe('createEmailTransportWithProvider', () => {
|
|
778
|
+
it('should create transport with custom provider', () => {
|
|
779
|
+
const provider = createMockProvider()
|
|
780
|
+
const transport = createEmailTransportWithProvider(provider, {
|
|
781
|
+
from: 'custom@example.com',
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
expect(transport.getProvider()).toBe(provider)
|
|
785
|
+
expect(transport.getConfig().from).toBe('custom@example.com')
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
// =============================================================================
|
|
791
|
+
// Integration Tests
|
|
792
|
+
// =============================================================================
|
|
793
|
+
|
|
794
|
+
describe('Integration', () => {
|
|
795
|
+
it('should handle full approval workflow', async () => {
|
|
796
|
+
const mockProvider = createMockProvider()
|
|
797
|
+
const transport = createEmailTransportWithProvider(mockProvider, {
|
|
798
|
+
from: 'approvals@example.com',
|
|
799
|
+
approvalBaseUrl: 'https://app.example.com/approve',
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
// 1. Send approval request
|
|
803
|
+
const sendResult = await transport.sendApprovalRequest({
|
|
804
|
+
to: 'manager@example.com',
|
|
805
|
+
request: 'Deploy v2.0 to production',
|
|
806
|
+
requestId: 'apr_deploy_123',
|
|
807
|
+
context: { version: '2.0', environment: 'production' },
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
expect(sendResult.success).toBe(true)
|
|
811
|
+
|
|
812
|
+
// 2. Simulate email reply
|
|
813
|
+
const replyEmail: InboundEmail = {
|
|
814
|
+
from: 'manager@example.com',
|
|
815
|
+
to: 'approvals@example.com',
|
|
816
|
+
subject: 'Re: Approval Required: Deploy v2.0 to production',
|
|
817
|
+
text: 'APPROVED\n\nLooks good, proceed with deployment.\n\nRequest ID: apr_deploy_123',
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// 3. Parse reply
|
|
821
|
+
const parsed = transport.parseReply(replyEmail)
|
|
822
|
+
|
|
823
|
+
expect(parsed.isApprovalResponse).toBe(true)
|
|
824
|
+
expect(parsed.approved).toBe(true)
|
|
825
|
+
expect(parsed.requestId).toBe('apr_deploy_123')
|
|
826
|
+
expect(parsed.notes).toContain('proceed with deployment')
|
|
827
|
+
|
|
828
|
+
// 4. Convert to ApprovalResult
|
|
829
|
+
const result = transport.toApprovalResult(parsed, { id: 'manager', name: 'Manager' })
|
|
830
|
+
|
|
831
|
+
expect(result.approved).toBe(true)
|
|
832
|
+
expect(result.approvedBy?.id).toBe('manager')
|
|
833
|
+
expect(result.via).toBe('email')
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it('should handle notification with rich content', async () => {
|
|
837
|
+
const mockProvider = createMockProvider()
|
|
838
|
+
const transport = createEmailTransportWithProvider(mockProvider, {
|
|
839
|
+
from: 'notifications@example.com',
|
|
840
|
+
templates: {
|
|
841
|
+
brandName: 'Acme Workflows',
|
|
842
|
+
primaryColor: '#007bff',
|
|
843
|
+
footerText: 'Powered by Acme Workflows',
|
|
844
|
+
},
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
await transport.sendNotification({
|
|
848
|
+
to: 'team@example.com',
|
|
849
|
+
message: 'Weekly report is ready',
|
|
850
|
+
subject: 'Weekly Status Report',
|
|
851
|
+
priority: 'normal',
|
|
852
|
+
metadata: {
|
|
853
|
+
reportUrl: 'https://reports.example.com/weekly',
|
|
854
|
+
period: '2025-01-20 to 2025-01-27',
|
|
855
|
+
highlights: 'All KPIs met',
|
|
856
|
+
},
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
const sentEmail = (mockProvider.send as ReturnType<typeof vi.fn>).mock
|
|
860
|
+
.calls[0][0] as EmailMessage
|
|
861
|
+
expect(sentEmail.subject).toContain('Weekly Status Report')
|
|
862
|
+
expect(sentEmail.html).toContain('Weekly report is ready')
|
|
863
|
+
expect(sentEmail.html).toContain('reportUrl')
|
|
864
|
+
expect(sentEmail.text).toContain('Powered by Acme Workflows')
|
|
865
|
+
})
|
|
866
|
+
})
|