@elevasis/core 0.13.0 → 0.15.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/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/organization-model/index.d.ts +1 -1
- package/dist/organization-model/index.js +9 -2
- package/dist/test-utils/index.d.ts +463 -377
- package/dist/test-utils/index.js +9 -2
- package/package.json +1 -1
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +2336 -0
- package/src/business/acquisition/activity-events.test.ts +250 -0
- package/src/business/acquisition/activity-events.ts +7 -65
- package/src/business/acquisition/api-schemas.test.ts +1180 -0
- package/src/business/acquisition/api-schemas.ts +317 -73
- package/src/business/acquisition/crm-state-actions.test.ts +160 -0
- package/src/business/acquisition/derive-actions.test.ts +518 -0
- package/src/business/acquisition/derive-actions.ts +101 -78
- package/src/business/acquisition/index.ts +51 -9
- package/src/business/acquisition/stateful.ts +30 -0
- package/src/business/acquisition/types.ts +48 -80
- package/src/execution/engine/index.ts +437 -434
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +363 -360
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/get-record/index.test.ts +162 -186
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-records/index.test.ts +316 -338
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-adapter.ts +204 -210
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.test.ts +88 -0
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.ts +141 -134
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/utils/types.ts +76 -75
- package/src/execution/engine/tools/integration/service.test.ts +34 -9
- package/src/execution/engine/tools/integration/service.ts +6 -3
- package/src/execution/engine/tools/lead-service-types.ts +934 -874
- package/src/execution/engine/tools/platform/acquisition/types.ts +266 -260
- package/src/execution/engine/tools/registry.ts +701 -699
- package/src/execution/engine/tools/tool-maps.ts +30 -2
- package/src/execution/engine/workflow/types.ts +11 -0
- package/src/organization-model/contracts.ts +4 -4
- package/src/organization-model/domains/navigation.ts +62 -62
- package/src/organization-model/domains/sales.test.ts +189 -0
- package/src/organization-model/domains/sales.ts +456 -94
- package/src/organization-model/published.ts +21 -21
- package/src/organization-model/resolve.ts +21 -8
- package/src/platform/constants/versions.ts +1 -1
- package/src/reference/_generated/contracts.md +2336 -0
- package/src/supabase/database.types.ts +2958 -2886
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AcqDealRow } from './types'
|
|
3
|
+
import type { Action, ActionDef } from './derive-actions'
|
|
4
|
+
import { DEFAULT_CRM_ACTIONS, SendReplyActionPayloadSchema, deriveActions } from './derive-actions'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixture builder
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a minimal AcqDealRow suitable for `isAvailableFor()` predicate checks.
|
|
12
|
+
* Only stage_key and state_key affect availability logic; all other required
|
|
13
|
+
* columns are filled with safe, inert defaults.
|
|
14
|
+
*/
|
|
15
|
+
function makeDeal(overrides: Partial<Pick<AcqDealRow, 'stage_key' | 'state_key'>> = {}): AcqDealRow {
|
|
16
|
+
return {
|
|
17
|
+
id: 'deal-fixture-id',
|
|
18
|
+
organization_id: 'org-fixture-id',
|
|
19
|
+
contact_id: null,
|
|
20
|
+
contact_email: 'fixture@example.com',
|
|
21
|
+
pipeline_key: 'default',
|
|
22
|
+
stage_key: null,
|
|
23
|
+
state_key: null,
|
|
24
|
+
activity_log: [],
|
|
25
|
+
closed_lost_at: null,
|
|
26
|
+
closed_lost_reason: null,
|
|
27
|
+
discovery_data: null,
|
|
28
|
+
discovery_submitted_at: null,
|
|
29
|
+
discovery_submitted_by: null,
|
|
30
|
+
initial_fee: null,
|
|
31
|
+
monthly_fee: null,
|
|
32
|
+
payment_link_sent_at: null,
|
|
33
|
+
payment_received_at: null,
|
|
34
|
+
proposal_data: null,
|
|
35
|
+
proposal_generated_at: null,
|
|
36
|
+
proposal_pdf_url: null,
|
|
37
|
+
proposal_reviewed_at: null,
|
|
38
|
+
proposal_reviewed_by: null,
|
|
39
|
+
proposal_sent_at: null,
|
|
40
|
+
proposal_signed_at: null,
|
|
41
|
+
signature_envelope_id: null,
|
|
42
|
+
source_list_id: null,
|
|
43
|
+
source_type: null,
|
|
44
|
+
stripe_payment_id: null,
|
|
45
|
+
stripe_payment_link: null,
|
|
46
|
+
stripe_payment_link_id: null,
|
|
47
|
+
stripe_subscription_id: null,
|
|
48
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
49
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
50
|
+
...overrides
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// DEFAULT_CRM_ACTIONS static contract
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('DEFAULT_CRM_ACTIONS static contract', () => {
|
|
59
|
+
const EXPECTED_KEYS = [
|
|
60
|
+
'move_to_proposal',
|
|
61
|
+
'move_to_closing',
|
|
62
|
+
'move_to_closed_won',
|
|
63
|
+
'move_to_closed_lost',
|
|
64
|
+
'move_to_nurturing',
|
|
65
|
+
'send_reply',
|
|
66
|
+
'send_link',
|
|
67
|
+
'send_nudge',
|
|
68
|
+
'mark_no_show',
|
|
69
|
+
'rebook'
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
it('contains all 10 expected action keys', () => {
|
|
73
|
+
const keys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
74
|
+
expect(keys).toEqual(EXPECTED_KEYS)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('every entry has key, label, isAvailableFor, and workflowId', () => {
|
|
78
|
+
for (const def of DEFAULT_CRM_ACTIONS) {
|
|
79
|
+
expect(typeof def.key).toBe('string')
|
|
80
|
+
expect(def.key.length).toBeGreaterThan(0)
|
|
81
|
+
expect(typeof def.label).toBe('string')
|
|
82
|
+
expect(def.label.length).toBeGreaterThan(0)
|
|
83
|
+
expect(typeof def.isAvailableFor).toBe('function')
|
|
84
|
+
expect(typeof def.workflowId).toBe('string')
|
|
85
|
+
expect(def.workflowId.length).toBeGreaterThan(0)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('no two ActionDefs share the same key', () => {
|
|
90
|
+
const keys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
91
|
+
const unique = new Set(keys)
|
|
92
|
+
expect(unique.size).toBe(keys.length)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// isAvailableFor() predicates — per stage / state
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('isAvailableFor() predicates', () => {
|
|
101
|
+
describe('move_to_proposal', () => {
|
|
102
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_proposal')!
|
|
103
|
+
|
|
104
|
+
it('is available for interested stage', () => {
|
|
105
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested' }))).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it.each(['proposal', 'closing', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
109
|
+
'is NOT available for stage %s',
|
|
110
|
+
(stage) => {
|
|
111
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('move_to_closing', () => {
|
|
117
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closing')!
|
|
118
|
+
|
|
119
|
+
it('is available for proposal stage', () => {
|
|
120
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'proposal' }))).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it.each(['interested', 'closing', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
124
|
+
'is NOT available for stage %s',
|
|
125
|
+
(stage) => {
|
|
126
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('move_to_closed_won', () => {
|
|
132
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closed_won')!
|
|
133
|
+
|
|
134
|
+
it('is available for closing stage', () => {
|
|
135
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'closing' }))).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it.each(['interested', 'proposal', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
139
|
+
'is NOT available for stage %s',
|
|
140
|
+
(stage) => {
|
|
141
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('move_to_closed_lost', () => {
|
|
147
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closed_lost')!
|
|
148
|
+
|
|
149
|
+
it.each(['interested', 'proposal', 'closing'])('is available for stage %s', (stage) => {
|
|
150
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('is NOT available for stage %s', (stage) => {
|
|
154
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('move_to_nurturing', () => {
|
|
159
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_nurturing')!
|
|
160
|
+
|
|
161
|
+
it.each(['interested', 'proposal', 'closing'])('is available for stage %s', (stage) => {
|
|
162
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('is NOT available for stage %s', (stage) => {
|
|
166
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('send_link', () => {
|
|
171
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'send_link')!
|
|
172
|
+
|
|
173
|
+
it('is available for interested/discovery_replied', () => {
|
|
174
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it.each([
|
|
178
|
+
{ stage_key: 'interested', state_key: null },
|
|
179
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
180
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
181
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
182
|
+
{ stage_key: 'proposal', state_key: 'discovery_replied' },
|
|
183
|
+
{ stage_key: null, state_key: null }
|
|
184
|
+
])('is NOT available for %o', (overrides) => {
|
|
185
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('send_nudge', () => {
|
|
190
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'send_nudge')!
|
|
191
|
+
|
|
192
|
+
it.each(['discovery_link_sent', 'discovery_nudging'])('is available for interested/%s', (state) => {
|
|
193
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: state }))).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it.each([
|
|
197
|
+
{ stage_key: 'interested', state_key: null },
|
|
198
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
199
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
200
|
+
{ stage_key: 'proposal', state_key: 'discovery_link_sent' },
|
|
201
|
+
{ stage_key: null, state_key: null }
|
|
202
|
+
])('is NOT available for %o', (overrides) => {
|
|
203
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('mark_no_show', () => {
|
|
208
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'mark_no_show')!
|
|
209
|
+
|
|
210
|
+
it('is available for interested/discovery_nudging', () => {
|
|
211
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_nudging' }))).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it.each([
|
|
215
|
+
{ stage_key: 'interested', state_key: null },
|
|
216
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
217
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
218
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
219
|
+
{ stage_key: 'proposal', state_key: 'discovery_nudging' },
|
|
220
|
+
{ stage_key: null, state_key: null }
|
|
221
|
+
])('is NOT available for %o', (overrides) => {
|
|
222
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('rebook', () => {
|
|
227
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'rebook')!
|
|
228
|
+
|
|
229
|
+
it('is available for interested/discovery_booking_cancelled', () => {
|
|
230
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_booking_cancelled' }))).toBe(
|
|
231
|
+
true
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it.each([
|
|
236
|
+
{ stage_key: 'interested', state_key: null },
|
|
237
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
238
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
239
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
240
|
+
{ stage_key: 'proposal', state_key: 'discovery_booking_cancelled' },
|
|
241
|
+
{ stage_key: null, state_key: null }
|
|
242
|
+
])('is NOT available for %o', (overrides) => {
|
|
243
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('terminal stages produce empty action lists', () => {
|
|
248
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('returns [] for stage %s', (stage) => {
|
|
249
|
+
expect(deriveActions(makeDeal({ stage_key: stage }))).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// deriveActions() integration
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe('deriveActions()', () => {
|
|
259
|
+
it('returns render-time shape { key, label } only — no isAvailableFor, workflowId, or payload fields', () => {
|
|
260
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
261
|
+
|
|
262
|
+
expect(actions.length).toBeGreaterThan(0)
|
|
263
|
+
for (const action of actions) {
|
|
264
|
+
expect(action).not.toHaveProperty('isAvailableFor')
|
|
265
|
+
expect(action).not.toHaveProperty('workflowId')
|
|
266
|
+
// Old fields from prior shape
|
|
267
|
+
expect(action).not.toHaveProperty('available')
|
|
268
|
+
expect(action).not.toHaveProperty('handler')
|
|
269
|
+
expect(action).not.toHaveProperty('kind')
|
|
270
|
+
expect(action).not.toHaveProperty('payload')
|
|
271
|
+
// Required render-time fields
|
|
272
|
+
expect(action).toHaveProperty('key')
|
|
273
|
+
expect(action).toHaveProperty('label')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('actions without payloadSchema render with payloadSchema undefined (button branch)', () => {
|
|
278
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
279
|
+
for (const action of actions) {
|
|
280
|
+
expect(action.payloadSchema).toBeUndefined()
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('custom action set works: deriveActions(deal, [customAction]) filters by the override', () => {
|
|
285
|
+
const alwaysAvailableAction: ActionDef = {
|
|
286
|
+
key: 'custom_action',
|
|
287
|
+
label: 'Custom Action',
|
|
288
|
+
isAvailableFor: () => true,
|
|
289
|
+
workflowId: 'custom-action-workflow'
|
|
290
|
+
}
|
|
291
|
+
const neverAvailableAction: ActionDef = {
|
|
292
|
+
key: 'blocked_action',
|
|
293
|
+
label: 'Blocked Action',
|
|
294
|
+
isAvailableFor: () => false,
|
|
295
|
+
workflowId: 'blocked-action-workflow'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }), [alwaysAvailableAction, neverAvailableAction])
|
|
299
|
+
|
|
300
|
+
expect(actions).toEqual([{ key: 'custom_action', label: 'Custom Action' }])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('empty action set returns empty array', () => {
|
|
304
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested' }), [])).toEqual([])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('uses DEFAULT_CRM_ACTIONS when no second argument is provided', () => {
|
|
308
|
+
const withDefaults = deriveActions(makeDeal({ stage_key: 'proposal' }))
|
|
309
|
+
const withExplicit = deriveActions(makeDeal({ stage_key: 'proposal' }), DEFAULT_CRM_ACTIONS)
|
|
310
|
+
expect(withDefaults).toEqual(withExplicit)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// SendReplyActionPayloadSchema boundary tests
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('SendReplyActionPayloadSchema', () => {
|
|
319
|
+
it('rejects empty string', () => {
|
|
320
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: '' }).success).toBe(false)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('rejects whitespace-only string after trim', () => {
|
|
324
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: ' ' }).success).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('accepts single character after trim', () => {
|
|
328
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x' }).success).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('accepts string at the 10000-character limit', () => {
|
|
332
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x'.repeat(10000) }).success).toBe(true)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('rejects string exceeding 10000 characters', () => {
|
|
336
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x'.repeat(10001) }).success).toBe(false)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('trims leading and trailing whitespace from replyBody', () => {
|
|
340
|
+
const result = SendReplyActionPayloadSchema.safeParse({ replyBody: ' hello ' })
|
|
341
|
+
expect(result.success).toBe(true)
|
|
342
|
+
if (result.success) {
|
|
343
|
+
expect(result.data.replyBody).toBe('hello')
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('rejects unknown fields (strict schema)', () => {
|
|
348
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'hello', extra: 'field' }).success).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// deriveActions() — custom action set isolation
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
describe('deriveActions() — custom action set isolation', () => {
|
|
357
|
+
it('filters only against the provided set — does not merge with DEFAULT_CRM_ACTIONS', () => {
|
|
358
|
+
const customOnly: ActionDef = {
|
|
359
|
+
key: 'custom_only',
|
|
360
|
+
label: 'Custom Only',
|
|
361
|
+
isAvailableFor: () => true,
|
|
362
|
+
workflowId: 'custom-only-workflow'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = deriveActions(makeDeal({ stage_key: 'interested' }), [customOnly])
|
|
366
|
+
|
|
367
|
+
const resultKeys = result.map((a) => a.key)
|
|
368
|
+
expect(resultKeys).toEqual(['custom_only'])
|
|
369
|
+
const defaultKeys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
370
|
+
for (const key of defaultKeys) {
|
|
371
|
+
expect(resultKeys).not.toContain(key)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('always-available custom action appears for any deal state', () => {
|
|
376
|
+
const alwaysOn: ActionDef = {
|
|
377
|
+
key: 'always_on',
|
|
378
|
+
label: 'Always On',
|
|
379
|
+
isAvailableFor: () => true,
|
|
380
|
+
workflowId: 'always-on-workflow'
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const stage of ['interested', 'proposal', 'closing', 'closed_won', 'closed_lost', 'nurturing', null]) {
|
|
384
|
+
const result = deriveActions(makeDeal({ stage_key: stage }), [alwaysOn])
|
|
385
|
+
expect(result).toEqual([{ key: 'always_on', label: 'Always On' }])
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// deriveActions() — action metadata preservation
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
describe('deriveActions() — action metadata preservation', () => {
|
|
395
|
+
it('send_reply action includes payloadSchema in output', () => {
|
|
396
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))
|
|
397
|
+
const sendReply = actions.find((a) => a.key === 'send_reply')
|
|
398
|
+
expect(sendReply).toBeDefined()
|
|
399
|
+
expect(sendReply?.payloadSchema).toBeDefined()
|
|
400
|
+
expect(sendReply?.payloadSchema).toBe(SendReplyActionPayloadSchema)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('move_to_proposal does NOT include payloadSchema', () => {
|
|
404
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
405
|
+
const action = actions.find((a) => a.key === 'move_to_proposal')
|
|
406
|
+
expect(action).toBeDefined()
|
|
407
|
+
expect(action?.payloadSchema).toBeUndefined()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('returned actions preserve key and label properties', () => {
|
|
411
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
412
|
+
expect(actions.length).toBeGreaterThan(0)
|
|
413
|
+
for (const action of actions) {
|
|
414
|
+
expect(typeof action.key).toBe('string')
|
|
415
|
+
expect(action.key.length).toBeGreaterThan(0)
|
|
416
|
+
expect(typeof action.label).toBe('string')
|
|
417
|
+
expect(action.label.length).toBeGreaterThan(0)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// deriveActions() — deduplication and ordering
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
describe('deriveActions() — deduplication and ordering', () => {
|
|
427
|
+
it.each([
|
|
428
|
+
{ stage_key: 'interested', state_key: null },
|
|
429
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
430
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
431
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
432
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
433
|
+
{ stage_key: 'proposal', state_key: null },
|
|
434
|
+
{ stage_key: 'closing', state_key: null }
|
|
435
|
+
] as const)('no duplicate action keys for %o', (overrides) => {
|
|
436
|
+
const actions = deriveActions(makeDeal(overrides))
|
|
437
|
+
const keys = actions.map((a) => a.key)
|
|
438
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('returns the same order for repeated calls with identical input', () => {
|
|
442
|
+
const deal = makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' })
|
|
443
|
+
const first = deriveActions(deal).map((a) => a.key)
|
|
444
|
+
const second = deriveActions(deal).map((a) => a.key)
|
|
445
|
+
expect(first).toEqual(second)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// deriveActions() — multi-state discovery path coverage
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
describe('deriveActions() — multi-state discovery path coverage', () => {
|
|
454
|
+
it('interested + null state: base actions only, no state-specific actions', () => {
|
|
455
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: null }))).toEqual([
|
|
456
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
457
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
458
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
459
|
+
])
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('interested + discovery_replied: base actions plus send_reply and send_link', () => {
|
|
463
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))).toEqual([
|
|
464
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
465
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
466
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
467
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
468
|
+
{ key: 'send_link', label: 'Send Booking Link' }
|
|
469
|
+
])
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('interested + discovery_link_sent: base actions plus send_reply and send_nudge', () => {
|
|
473
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_link_sent' }))).toEqual([
|
|
474
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
475
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
476
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
477
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
478
|
+
{ key: 'send_nudge', label: 'Send Nudge' }
|
|
479
|
+
])
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('interested + discovery_nudging: base actions plus send_reply, send_nudge, and mark_no_show', () => {
|
|
483
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_nudging' }))).toEqual([
|
|
484
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
485
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
486
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
487
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
488
|
+
{ key: 'send_nudge', label: 'Send Nudge' },
|
|
489
|
+
{ key: 'mark_no_show', label: 'Mark No-Show' }
|
|
490
|
+
])
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Type-level smoke tests (compile-time only, no runtime assertions)
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
describe('type-level smoke', () => {
|
|
499
|
+
it('Action and ActionDef are usable as TypeScript types', () => {
|
|
500
|
+
// If this file compiles without error, these types are valid at the use-site.
|
|
501
|
+
function acceptsAction(_action: Action): void {}
|
|
502
|
+
function acceptsActionDef(_def: ActionDef): void {}
|
|
503
|
+
|
|
504
|
+
const sampleAction: Action = { key: 'test', label: 'Test' }
|
|
505
|
+
const sampleDef: ActionDef = {
|
|
506
|
+
key: 'test',
|
|
507
|
+
label: 'Test',
|
|
508
|
+
isAvailableFor: () => true,
|
|
509
|
+
workflowId: 'test-workflow'
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
acceptsAction(sampleAction)
|
|
513
|
+
acceptsActionDef(sampleDef)
|
|
514
|
+
|
|
515
|
+
// No runtime assertion needed — compilation success is the contract
|
|
516
|
+
expect(true).toBe(true)
|
|
517
|
+
})
|
|
518
|
+
})
|