@beechcms/api 0.4.1 → 0.4.3

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.
Files changed (67) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-BMkd1Irh.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/factory.ts +12 -4
  8. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  9. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  10. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  11. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  12. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  13. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  14. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  15. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  16. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  17. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  18. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  19. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  20. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  21. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  22. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  23. package/src/features/automations/action-executors/index.ts +33 -0
  24. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  25. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  26. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  27. package/src/features/automations/automation-runner.ts +81 -0
  28. package/src/features/automations/automation-runner.utils.ts +43 -0
  29. package/src/features/automations/automations.handler.ts +193 -0
  30. package/src/features/automations/automations.schema.ts +160 -0
  31. package/src/features/automations/context-resolver.ts +148 -0
  32. package/src/features/automations/cron-runner.ts +136 -0
  33. package/src/features/automations/cron-runner.utils.ts +40 -0
  34. package/src/features/automations/filter-translation.ts +42 -0
  35. package/src/features/automations/index.ts +12 -0
  36. package/src/features/automations/template-grammar.ts +241 -0
  37. package/src/features/automations/var-access-resolver.ts +136 -0
  38. package/src/features/automations/when-evaluator.ts +83 -0
  39. package/src/features/automations/when-pushdown.ts +53 -0
  40. package/src/features/content/handlers/create.ts +8 -0
  41. package/src/features/content/handlers/delete.ts +8 -1
  42. package/src/features/content/handlers/update.ts +8 -0
  43. package/src/features/draft/draft.handler.ts +51 -164
  44. package/src/features/draft/draft.middleware.ts +62 -0
  45. package/src/features/email/email.service.ts +13 -0
  46. package/src/features/email/email.types.ts +10 -0
  47. package/src/features/email/index.ts +2 -1
  48. package/src/features/email/templates/automation-mail.ts +15 -0
  49. package/src/features/settings/settings.handler.ts +2 -1
  50. package/src/index.ts +40 -8
  51. package/src/middleware/repository.middleware.ts +32 -1
  52. package/src/public/cache-utils.ts +34 -0
  53. package/src/public/entry-projection.ts +42 -0
  54. package/src/public/idempotency.ts +19 -0
  55. package/src/public/problem-details.ts +5 -0
  56. package/src/public/public-add.ts +17 -63
  57. package/src/public/public-read.ts +20 -177
  58. package/src/public/read-list.ts +50 -0
  59. package/src/public/read-single.ts +44 -0
  60. package/src/shared/automations.repository.d1.ts +146 -0
  61. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  62. package/src/shared/execution-context-scheduler.ts +9 -0
  63. package/src/shared/storage-utils.ts +3 -3
  64. package/src/types.ts +5 -1
  65. package/src/upload.ts +3 -2
  66. package/assets/dashboard/assets/index-CH13idU1.js +0 -554
  67. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { Hono } from 'hono'
3
+ import type { IAutomationRepository, Automation } from '@beechcms/core'
4
+ import { automationsApp } from '../automations.handler'
5
+
6
+ const baseAutomation: Automation = {
7
+ id: 'test-id',
8
+ seed_slug: 'posts',
9
+ name: 'test',
10
+ enabled: true,
11
+ triggers: [{ event: 'create' }],
12
+ trigger_conditions: null,
13
+ actions: [{ type: 'webhook', url: 'https://example.com' }],
14
+ created_at: 1000,
15
+ updated_at: 1000,
16
+ }
17
+
18
+ function makeStub(overrides: Partial<IAutomationRepository> = {}): IAutomationRepository {
19
+ return {
20
+ list: vi.fn().mockResolvedValue([baseAutomation]),
21
+ findById: vi.fn().mockResolvedValue(baseAutomation),
22
+ findActive: vi.fn().mockResolvedValue([]),
23
+ create: vi.fn().mockResolvedValue('new-id'),
24
+ update: vi.fn().mockResolvedValue(undefined),
25
+ toggle: vi.fn().mockResolvedValue(undefined),
26
+ delete: vi.fn().mockResolvedValue(undefined),
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ function buildApp(stub: IAutomationRepository) {
32
+ const app = new Hono()
33
+ app.use('*', async (c, next) => {
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ ;(c as any).set('automationRepository', stub)
36
+ await next()
37
+ })
38
+ app.route('/', automationsApp)
39
+ return app
40
+ }
41
+
42
+ describe('GET /', () => {
43
+ it('returns 400 missing-seed without ?seed=', async () => {
44
+ const app = buildApp(makeStub())
45
+ const res = await app.request('/')
46
+ expect(res.status).toBe(400)
47
+ const body = await res.json() as { type: string }
48
+ expect(body.type).toContain('missing-seed')
49
+ })
50
+
51
+ it('returns 200 with automations list', async () => {
52
+ const app = buildApp(makeStub())
53
+ const res = await app.request('/?seed=posts')
54
+ expect(res.status).toBe(200)
55
+ const body = await res.json() as Automation[]
56
+ expect(body).toHaveLength(1)
57
+ expect(body[0].id).toBe('test-id')
58
+ })
59
+ })
60
+
61
+ describe('POST /', () => {
62
+ it('returns 400 invalid-json for malformed JSON body', async () => {
63
+ const app = buildApp(makeStub())
64
+ const res = await app.request('/', {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: 'malformed-not-json',
68
+ })
69
+ expect(res.status).toBe(400)
70
+ const body = await res.json() as { type: string }
71
+ expect(body.type).toContain('invalid-json')
72
+ })
73
+
74
+ it('returns 400 automation-validation-failed for schema non-compliant body', async () => {
75
+ const app = buildApp(makeStub())
76
+ const res = await app.request('/', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ seed_slug: 'posts' }), // missing name, triggers, actions
80
+ })
81
+ expect(res.status).toBe(400)
82
+ const body = await res.json() as { type: string }
83
+ expect(body.type).toContain('automation-validation-failed')
84
+ })
85
+
86
+ it('returns 201 with id for valid body including optional trigger fields', async () => {
87
+ const app = buildApp(makeStub())
88
+ const res = await app.request('/', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({
92
+ seed_slug: 'posts',
93
+ name: 'test-cron-automation',
94
+ triggers: [{ event: 'cron', cron: '0 0 * * *' }],
95
+ trigger_conditions: {
96
+ kind: 'group',
97
+ op: 'AND',
98
+ children: [
99
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'draft' } },
100
+ ],
101
+ },
102
+ actions: [{ type: 'webhook', url: 'https://example.com' }],
103
+ }),
104
+ })
105
+ expect(res.status).toBe(201)
106
+ const body = await res.json() as { id: string }
107
+ expect(body.id).toBe('new-id')
108
+ })
109
+
110
+ it('returns 201 with id for valid body omitting optional trigger fields', async () => {
111
+ const app = buildApp(makeStub())
112
+ const res = await app.request('/', {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({
116
+ seed_slug: 'posts',
117
+ name: 'test-create-automation',
118
+ triggers: [{ event: 'create' }],
119
+ actions: [{ type: 'webhook', url: 'https://example.com' }],
120
+ }),
121
+ })
122
+ expect(res.status).toBe(201)
123
+ const body = await res.json() as { id: string }
124
+ expect(body.id).toBe('new-id')
125
+ })
126
+ })
127
+
128
+ describe('GET /:id', () => {
129
+ it('returns 404 when not found', async () => {
130
+ const app = buildApp(makeStub({ findById: vi.fn().mockResolvedValue(null) }))
131
+ const res = await app.request('/unknown-id')
132
+ expect(res.status).toBe(404)
133
+ })
134
+
135
+ it('returns 200 with automation', async () => {
136
+ const app = buildApp(makeStub())
137
+ const res = await app.request('/test-id')
138
+ expect(res.status).toBe(200)
139
+ })
140
+ })
141
+
142
+ describe('PUT /:id', () => {
143
+ it('returns 404 when not found', async () => {
144
+ const app = buildApp(makeStub({ findById: vi.fn().mockResolvedValue(null) }))
145
+ const res = await app.request('/unknown-id', {
146
+ method: 'PUT',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ name: 'renamed' }),
149
+ })
150
+ expect(res.status).toBe(404)
151
+ })
152
+
153
+ it('returns 400 invalid-json for malformed JSON body', async () => {
154
+ const app = buildApp(makeStub())
155
+ const res = await app.request('/test-id', {
156
+ method: 'PUT',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: 'malformed-not-json',
159
+ })
160
+ expect(res.status).toBe(400)
161
+ const body = await res.json() as { type: string }
162
+ expect(body.type).toContain('invalid-json')
163
+ })
164
+
165
+ it('returns 400 automation-validation-failed for invalid update values', async () => {
166
+ const app = buildApp(makeStub())
167
+ const res = await app.request('/test-id', {
168
+ method: 'PUT',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({ name: 12345 }), // name must be string
171
+ })
172
+ expect(res.status).toBe(400)
173
+ const body = await res.json() as { type: string }
174
+ expect(body.type).toContain('automation-validation-failed')
175
+ })
176
+
177
+ it('returns 400 when cron trigger has no cron expression', async () => {
178
+ const app = buildApp(makeStub())
179
+ const res = await app.request('/test-id', {
180
+ method: 'PUT',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ triggers: [{ event: 'cron' }] }),
183
+ })
184
+ expect(res.status).toBe(400)
185
+ const body = await res.json() as { type: string }
186
+ expect(body.type).toContain('automation-validation-failed')
187
+ })
188
+
189
+ it('returns 204 for valid update', async () => {
190
+ const app = buildApp(makeStub())
191
+ const res = await app.request('/test-id', {
192
+ method: 'PUT',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ name: 'renamed' }),
195
+ })
196
+ expect(res.status).toBe(204)
197
+ })
198
+ })
199
+
200
+ describe('PATCH /:id/toggle', () => {
201
+ it('returns 404 when automation not found', async () => {
202
+ const app = buildApp(makeStub({ findById: vi.fn().mockResolvedValue(null) }))
203
+ const res = await app.request('/unknown-id/toggle', {
204
+ method: 'PATCH',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ enabled: false }),
207
+ })
208
+ expect(res.status).toBe(404)
209
+ })
210
+
211
+ it('returns 400 invalid-json for malformed JSON body', async () => {
212
+ const app = buildApp(makeStub())
213
+ const res = await app.request('/test-id/toggle', {
214
+ method: 'PATCH',
215
+ headers: { 'Content-Type': 'application/json' },
216
+ body: 'malformed-not-json',
217
+ })
218
+ expect(res.status).toBe(400)
219
+ const body = await res.json() as { type: string }
220
+ expect(body.type).toContain('invalid-json')
221
+ })
222
+
223
+ it('returns 400 automation-validation-failed for invalid payload', async () => {
224
+ const app = buildApp(makeStub())
225
+ const res = await app.request('/test-id/toggle', {
226
+ method: 'PATCH',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ enabled: 'not-a-boolean' }),
229
+ })
230
+ expect(res.status).toBe(400)
231
+ const body = await res.json() as { type: string }
232
+ expect(body.type).toContain('automation-validation-failed')
233
+ })
234
+
235
+ it('returns 204 and calls toggle(id, false)', async () => {
236
+ const stub = makeStub()
237
+ const app = buildApp(stub)
238
+ const res = await app.request('/test-id/toggle', {
239
+ method: 'PATCH',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ enabled: false }),
242
+ })
243
+ expect(res.status).toBe(204)
244
+ expect(stub.toggle).toHaveBeenCalledWith('test-id', false)
245
+ })
246
+ })
247
+
248
+ describe('DELETE /:id', () => {
249
+ it('returns 404 when not found', async () => {
250
+ const app = buildApp(makeStub({ findById: vi.fn().mockResolvedValue(null) }))
251
+ const res = await app.request('/unknown-id', { method: 'DELETE' })
252
+ expect(res.status).toBe(404)
253
+ })
254
+
255
+ it('returns 204 on success', async () => {
256
+ const app = buildApp(makeStub())
257
+ const res = await app.request('/test-id', { method: 'DELETE' })
258
+ expect(res.status).toBe(204)
259
+ })
260
+ })
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1AutomationRepository } from '../../../shared/automations.repository.d1'
3
+
4
+ function makeDb() {
5
+ const calls: { sql: string; bindings: unknown[] }[] = []
6
+ let nextAllResult: unknown[] = []
7
+ let nextFirstResult: unknown = null
8
+
9
+ const db = {
10
+ _calls: calls,
11
+ _setAllResult(rows: unknown[]) { nextAllResult = rows },
12
+ _setFirstResult(row: unknown) { nextFirstResult = row },
13
+ prepare(sql: string) {
14
+ let boundValues: unknown[] = []
15
+ const stmt = {
16
+ bind(...args: unknown[]) { boundValues = args; return stmt },
17
+ async run() {
18
+ calls.push({ sql, bindings: boundValues })
19
+ return { success: true }
20
+ },
21
+ async all() {
22
+ calls.push({ sql, bindings: boundValues })
23
+ return { results: nextAllResult }
24
+ },
25
+ async first() {
26
+ calls.push({ sql, bindings: boundValues })
27
+ return nextFirstResult
28
+ },
29
+ }
30
+ return stmt
31
+ },
32
+ }
33
+ return db as unknown as D1Database & typeof db
34
+ }
35
+
36
+ const fixtureRow = {
37
+ id: 'abc',
38
+ seed_slug: 'posts',
39
+ name: 'test',
40
+ enabled: 1,
41
+ triggers: JSON.stringify([{ event: 'create' }]),
42
+ trigger_conditions: null,
43
+ actions: JSON.stringify([{ type: 'webhook', url: 'https://example.com' }]),
44
+ created_at: 1000,
45
+ updated_at: 1000,
46
+ }
47
+
48
+ describe('D1AutomationRepository.create', () => {
49
+ it('serialises actions and trigger_conditions to JSON', async () => {
50
+ const db = makeDb()
51
+ const repo = new D1AutomationRepository(db)
52
+ const whenNode = {
53
+ kind: 'group' as const,
54
+ op: 'AND' as const,
55
+ children: [{ kind: 'predicate' as const, left: { kind: 'ref' as const, key: 'this.status' }, op: 'eq' as const, right: { kind: 'literal' as const, value: 'published' } }],
56
+ }
57
+ await repo.create({
58
+ seed_slug: 'posts',
59
+ name: 'test',
60
+ triggers: [{ event: 'create' }],
61
+ trigger_conditions: whenNode,
62
+ actions: [{ type: 'webhook', url: 'https://example.com' }],
63
+ })
64
+ expect(db._calls).toHaveLength(1)
65
+ const { bindings } = db._calls[0]
66
+ // triggers is index 3, trigger_conditions is index 4, actions is index 5
67
+ expect(typeof bindings[3]).toBe('string')
68
+ expect(JSON.parse(bindings[3] as string)).toEqual([{ event: 'create' }])
69
+ expect(typeof bindings[4]).toBe('string')
70
+ expect(JSON.parse(bindings[4] as string)).toEqual(whenNode)
71
+ expect(typeof bindings[5]).toBe('string')
72
+ expect(JSON.parse(bindings[5] as string)).toEqual([{ type: 'webhook', url: 'https://example.com' }])
73
+ })
74
+
75
+ it('stores null for missing trigger_conditions', async () => {
76
+ const db = makeDb()
77
+ const repo = new D1AutomationRepository(db)
78
+ await repo.create({
79
+ seed_slug: 'posts',
80
+ name: 'test',
81
+ triggers: [{ event: 'create' }],
82
+ trigger_conditions: null,
83
+ actions: [{ type: 'webhook', url: 'https://example.com' }],
84
+ })
85
+ const { bindings } = db._calls[0]
86
+ expect(bindings[4]).toBeNull()
87
+ })
88
+ })
89
+
90
+ describe('D1AutomationRepository.update', () => {
91
+ it('builds SET clause only from provided keys', async () => {
92
+ const db = makeDb()
93
+ const repo = new D1AutomationRepository(db)
94
+ await repo.update('abc', { name: 'renamed' })
95
+ expect(db._calls).toHaveLength(1)
96
+ expect(db._calls[0].sql).toContain('name = ?')
97
+ expect(db._calls[0].sql).not.toContain('seed_slug')
98
+ })
99
+
100
+ it('short-circuits when no fields provided', async () => {
101
+ const db = makeDb()
102
+ const repo = new D1AutomationRepository(db)
103
+ await repo.update('abc', {})
104
+ expect(db._calls).toHaveLength(0)
105
+ })
106
+ })
107
+
108
+ describe('D1AutomationRepository.toggle', () => {
109
+ it('only writes enabled and updated_at', async () => {
110
+ const db = makeDb()
111
+ const repo = new D1AutomationRepository(db)
112
+ await repo.toggle('abc', false)
113
+ expect(db._calls).toHaveLength(1)
114
+ const { sql, bindings } = db._calls[0]
115
+ expect(sql).toBe('UPDATE automations SET enabled = ?, updated_at = ? WHERE id = ?')
116
+ expect(bindings[0]).toBe(0)
117
+ expect(bindings[2]).toBe('abc')
118
+ })
119
+ })
120
+
121
+ describe('rowToAutomation JSON round-trip', () => {
122
+ it('deserialises trigger_conditions as null when column is null', async () => {
123
+ const db = makeDb()
124
+ db._setAllResult([fixtureRow])
125
+ const repo = new D1AutomationRepository(db)
126
+ const rows = await repo.list('posts')
127
+ expect(rows[0].trigger_conditions).toBeNull()
128
+ })
129
+
130
+ it('deserialises trigger_conditions as WhenNode when column contains WhenNode JSON', async () => {
131
+ const db = makeDb()
132
+ const whenNode = {
133
+ kind: 'group',
134
+ op: 'AND',
135
+ children: [{
136
+ kind: 'predicate',
137
+ left: { kind: 'ref', key: 'this.status' },
138
+ op: 'eq',
139
+ right: { kind: 'literal', value: 'published' },
140
+ }],
141
+ }
142
+ const rowWithConditions = {
143
+ ...fixtureRow,
144
+ trigger_conditions: JSON.stringify(whenNode),
145
+ }
146
+ db._setAllResult([rowWithConditions])
147
+ const repo = new D1AutomationRepository(db)
148
+ const rows = await repo.list('posts')
149
+ expect(rows[0].trigger_conditions).toEqual(whenNode)
150
+ })
151
+
152
+ it('deserialises actions as parsed array', async () => {
153
+ const db = makeDb()
154
+ db._setAllResult([fixtureRow])
155
+ const repo = new D1AutomationRepository(db)
156
+ const rows = await repo.list('posts')
157
+ expect(rows[0].actions).toEqual([{ type: 'webhook', url: 'https://example.com' }])
158
+ })
159
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createAutomationSchema,
4
+ updateAutomationSchema,
5
+ toggleAutomationSchema,
6
+ } from '../automations.schema'
7
+
8
+ const validWebhookAction = { type: 'webhook' as const, url: 'https://example.com/hook' }
9
+
10
+ const minimalValid = {
11
+ seed_slug: 'posts',
12
+ name: 'notify-on-create',
13
+ triggers: [{ event: 'create' as const }],
14
+ actions: [validWebhookAction],
15
+ }
16
+
17
+ describe('createAutomationSchema', () => {
18
+ it('accepts minimal valid create payload', () => {
19
+ expect(createAutomationSchema.safeParse(minimalValid).success).toBe(true)
20
+ })
21
+
22
+ it('rejects cron trigger without cron expression', () => {
23
+ const result = createAutomationSchema.safeParse({
24
+ ...minimalValid,
25
+ triggers: [{ event: 'cron' }],
26
+ })
27
+ expect(result.success).toBe(false)
28
+ })
29
+
30
+ it('accepts cron trigger with cron expression', () => {
31
+ const result = createAutomationSchema.safeParse({
32
+ ...minimalValid,
33
+ triggers: [{ event: 'cron', cron: '0 * * * *' }],
34
+ })
35
+ expect(result.success).toBe(true)
36
+ })
37
+
38
+ it('rejects duplicate trigger events', () => {
39
+ const result = createAutomationSchema.safeParse({
40
+ ...minimalValid,
41
+ triggers: [{ event: 'create' }, { event: 'create' }],
42
+ })
43
+ expect(result.success).toBe(false)
44
+ })
45
+
46
+ it('accepts multiple distinct triggers', () => {
47
+ const result = createAutomationSchema.safeParse({
48
+ ...minimalValid,
49
+ triggers: [{ event: 'create' }, { event: 'update' }],
50
+ })
51
+ expect(result.success).toBe(true)
52
+ })
53
+
54
+ it('rejects empty actions array', () => {
55
+ const result = createAutomationSchema.safeParse({ ...minimalValid, actions: [] })
56
+ expect(result.success).toBe(false)
57
+ })
58
+
59
+ it('rejects unknown action type', () => {
60
+ const result = createAutomationSchema.safeParse({
61
+ ...minimalValid,
62
+ actions: [{ type: 'unknown_type', url: 'https://example.com' }],
63
+ })
64
+ expect(result.success).toBe(false)
65
+ })
66
+
67
+ it('rejects webhook with non-url', () => {
68
+ const result = createAutomationSchema.safeParse({
69
+ ...minimalValid,
70
+ actions: [{ type: 'webhook', url: 'not-a-url' }],
71
+ })
72
+ expect(result.success).toBe(false)
73
+ })
74
+
75
+ it('rejects send_mail with invalid email', () => {
76
+ const result = createAutomationSchema.safeParse({
77
+ ...minimalValid,
78
+ actions: [{ type: 'send_mail', to: 'not-an-email', subject_template: 'hi', body_template: 'body' }],
79
+ })
80
+ expect(result.success).toBe(false)
81
+ })
82
+
83
+ it('accepts trigger_conditions as null', () => {
84
+ const result = createAutomationSchema.safeParse({
85
+ ...minimalValid,
86
+ trigger_conditions: null,
87
+ })
88
+
89
+ expect(result.success).toBe(true)
90
+ })
91
+
92
+ it('accepts trigger_conditions as WhenNode', () => {
93
+ const result = createAutomationSchema.safeParse({
94
+ ...minimalValid,
95
+ trigger_conditions: {
96
+ kind: 'group',
97
+ op: 'AND',
98
+ children: [
99
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'published' } },
100
+ ],
101
+ },
102
+ })
103
+ expect(result.success).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe('updateAutomationSchema', () => {
108
+ it('accepts empty object', () => {
109
+ expect(updateAutomationSchema.safeParse({}).success).toBe(true)
110
+ })
111
+
112
+ it('accepts name-only without requiring triggers', () => {
113
+ const result = updateAutomationSchema.safeParse({ name: 'renamed' })
114
+ expect(result.success).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('toggleAutomationSchema', () => {
119
+ it('accepts { enabled: true }', () => {
120
+ expect(toggleAutomationSchema.safeParse({ enabled: true }).success).toBe(true)
121
+ })
122
+
123
+ it('accepts { enabled: false }', () => {
124
+ expect(toggleAutomationSchema.safeParse({ enabled: false }).success).toBe(true)
125
+ })
126
+
127
+ it('rejects missing enabled', () => {
128
+ expect(toggleAutomationSchema.safeParse({}).success).toBe(false)
129
+ })
130
+
131
+ it('rejects non-boolean enabled', () => {
132
+ expect(toggleAutomationSchema.safeParse({ enabled: 'true' }).success).toBe(false)
133
+ })
134
+ })
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { Automation } from '@beechcms/core'
3
+ import { resolveAutomationContext, deriveEntryContext } from '../context-resolver'
4
+ import { parseTemplateKey } from '../template-grammar'
5
+
6
+ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
7
+ return {
8
+ id: 'auto-1',
9
+ seed_slug: 'orders',
10
+ name: 'test',
11
+ enabled: true,
12
+ triggers: [{ event: 'cron' as const, cron: '* * * * *' }],
13
+ trigger_conditions: null,
14
+ actions: [],
15
+ created_at: 0,
16
+ updated_at: 0,
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('resolveAutomationContext', () => {
22
+ const triggerEntry = { id: 'e1', email: 'alice@example.com', product_id: 'p1' }
23
+ const batchEntries = [
24
+ { id: 'e1', total: 10, email: 'alice@example.com' },
25
+ { id: 'e2', total: 20, email: 'bob@example.com' },
26
+ { id: 'e3', total: 30, email: 'carol@example.com' },
27
+ ]
28
+
29
+ it('this scope resolves to triggering entry field', async () => {
30
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
31
+ expect(ctx.lookup(parseTemplateKey('this:email')!)).toBe('alice@example.com')
32
+ })
33
+
34
+ it('this. dot notation resolves to triggering entry field', async () => {
35
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
36
+ expect(ctx.lookup(parseTemplateKey('email')!)).toBe('alice@example.com')
37
+ expect(ctx.lookup(parseTemplateKey('this:product_id')!)).toBe('p1')
38
+ })
39
+
40
+ it('batch:all:count matches batchEntries length', async () => {
41
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, batchEntries)
42
+ expect(ctx.lookup(parseTemplateKey('batch:all:count')!)).toBe(3)
43
+ })
44
+
45
+ it('unknown scope → undefined + onMissing fires', async () => {
46
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
47
+ const missing: string[] = []
48
+ const val = ctx.lookup(parseTemplateKey('unknownseed:lastone:email')!, (f) => missing.push(f))
49
+ expect(val).toBeUndefined()
50
+ expect(missing).toHaveLength(1)
51
+ expect(missing[0]).toBe('unknownseed')
52
+ })
53
+
54
+ it('pluck truncates to 100 entries and appends truncation marker', async () => {
55
+ const rows = Array.from({ length: 150 }, (_, i) => ({ email: `u${i}@example.com` }))
56
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, rows)
57
+ const key = parseTemplateKey('batch:all:pluck:email')!
58
+ const result = ctx.lookup(key) as string
59
+ const values = result.split(', ')
60
+ expect(result.endsWith(' …')).toBe(true)
61
+ expect(values.length).toBe(100)
62
+ })
63
+
64
+ it('sum on non-numeric field values returns 0 and fires onMissing for NaN', async () => {
65
+ const ctx = await resolveAutomationContext(
66
+ makeAutomation(),
67
+ triggerEntry,
68
+ [{ id: 'e1', amount: 'not-a-number' }, { id: 'e2', amount: 'also-not' }],
69
+ )
70
+ const missing: string[] = []
71
+ const result = ctx.lookup(parseTemplateKey('batch:all:sum:amount')!, (f) => missing.push(f))
72
+ expect(result).toBe(0)
73
+ expect(missing.length).toBeGreaterThan(0)
74
+ })
75
+
76
+ describe('aggregates on batch', () => {
77
+ const rows = [
78
+ { id: 'e1', total: 10 },
79
+ { id: 'e2', total: 20 },
80
+ { id: 'e3', total: 30 },
81
+ ]
82
+
83
+ async function makeCtx(batchRows: Record<string, unknown>[]) {
84
+ return resolveAutomationContext(makeAutomation(), batchRows[0] ?? null, batchRows)
85
+ }
86
+
87
+ it('count', async () => {
88
+ const ctx = await makeCtx(rows)
89
+ expect(ctx.lookup(parseTemplateKey('batch:all:count')!)).toBe(3)
90
+ })
91
+
92
+ it('sum', async () => {
93
+ const ctx = await makeCtx(rows)
94
+ expect(ctx.lookup(parseTemplateKey('batch:all:sum:total')!)).toBe(60)
95
+ })
96
+
97
+ it('avg', async () => {
98
+ const ctx = await makeCtx(rows)
99
+ expect(ctx.lookup(parseTemplateKey('batch:all:avg:total')!)).toBe(20)
100
+ })
101
+
102
+ it('min', async () => {
103
+ const ctx = await makeCtx(rows)
104
+ expect(ctx.lookup(parseTemplateKey('batch:all:min:total')!)).toBe(10)
105
+ })
106
+
107
+ it('max', async () => {
108
+ const ctx = await makeCtx(rows)
109
+ expect(ctx.lookup(parseTemplateKey('batch:all:max:total')!)).toBe(30)
110
+ })
111
+ })
112
+ })
113
+
114
+ describe('deriveEntryContext', () => {
115
+ it('this scope returns new entry, batch scope delegates to base', async () => {
116
+ const batchRows = [{ id: 'e1', val: 1 }, { id: 'e2', val: 2 }]
117
+ const base = await resolveAutomationContext(makeAutomation(), batchRows[0] ?? null, batchRows)
118
+ const derived = deriveEntryContext(base, { id: 'e2', val: 2, email: 'derived@example.com' })
119
+ expect(derived.lookup(parseTemplateKey('this:email')!)).toBe('derived@example.com')
120
+ expect(derived.lookup(parseTemplateKey('batch:all:count')!)).toBe(2)
121
+ })
122
+ })