@beechcms/api 0.5.0 → 0.6.0-preview.2

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 (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,268 +1,255 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { executeAction } from '../action-executors'
3
- import type { ActionContext } from '../action-executors'
4
- import type { ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
5
-
6
- // ── Shared mock context factory ───────────────────────────────────────────────
7
-
8
- function makeCtx(overrides: Partial<ActionContext> = {}): ActionContext {
9
- const { entry: entryOverride, context: contextOverride, ...rest } = overrides
10
- const entry: Record<string, unknown> = entryOverride ?? { id: 'entry-1', title: 'Test Entry', status: 'published' }
11
- const context = contextOverride ?? {
12
- triggerEntry: entry,
13
- lookup(parsed: import('../template-grammar').ParsedKey, onMissing?: (f: string) => void) {
14
- if (parsed.kind === 'simple') {
15
- const val = entry[parsed.path]
16
- if (val == null && onMissing) onMissing(parsed.path)
17
- return val
18
- }
19
- if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.field) {
20
- return entry[parsed.field]
21
- }
22
- return undefined
23
- },
24
- }
25
- return {
26
- entry,
27
- env: { RESEND_API_KEY: 'test-key', EMAIL_FROM: 'test@example.com' },
28
- repository: {
29
- update: vi.fn().mockResolvedValue(undefined),
30
- create: vi.fn().mockResolvedValue(undefined),
31
- } as unknown as ContentRepository,
32
- getSeed: vi.fn().mockReturnValue({ branches: [] } as unknown as Seed),
33
- seed: { slug: 'posts', branches: [] } as unknown as Seed,
34
- idGenerator: { uuid: vi.fn().mockReturnValue('new-id-123') } as unknown as IIdGenerator,
35
- context,
36
- variables: {},
37
- ...rest,
38
- }
39
- }
40
-
41
- // ── webhook ───────────────────────────────────────────────────────────────────
42
-
43
- describe('webhook executor', () => {
44
- beforeEach(() => { vi.restoreAllMocks() })
45
-
46
- it('POSTs to URL with JSON body derived from entry when no body_template', async () => {
47
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 })
48
- vi.stubGlobal('fetch', fetchMock)
49
-
50
- const ctx = makeCtx()
51
- await executeAction({ type: 'webhook', url: 'https://example.com/hook' }, ctx)
52
-
53
- expect(fetchMock).toHaveBeenCalledOnce()
54
- const [url, init] = fetchMock.mock.calls[0]
55
- expect(url).toBe('https://example.com/hook')
56
- expect(init.method).toBe('POST')
57
- expect(JSON.parse(init.body)).toMatchObject({ id: 'entry-1', title: 'Test Entry' })
58
- })
59
-
60
- it('interpolates body_template with entry values', async () => {
61
- const fetchMock = vi.fn().mockResolvedValue({ ok: true })
62
- vi.stubGlobal('fetch', fetchMock)
63
-
64
- const ctx = makeCtx()
65
- await executeAction({ type: 'webhook', url: 'https://example.com/hook', body_template: '{"t":"{{title}}"}' }, ctx)
66
-
67
- const [, init] = fetchMock.mock.calls[0]
68
- expect(init.body).toBe('{"t":"Test Entry"}')
69
- })
70
-
71
- it('merges custom headers', async () => {
72
- const fetchMock = vi.fn().mockResolvedValue({ ok: true })
73
- vi.stubGlobal('fetch', fetchMock)
74
-
75
- const ctx = makeCtx()
76
- await executeAction({ type: 'webhook', url: 'https://x.com', headers: { 'X-Token': 'abc' } }, ctx)
77
-
78
- const [, init] = fetchMock.mock.calls[0]
79
- expect(init.headers['X-Token']).toBe('abc')
80
- })
81
-
82
- it('throws when response is not ok', async () => {
83
- vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }))
84
- const ctx = makeCtx()
85
- await expect(executeAction({ type: 'webhook', url: 'https://x.com' }, ctx)).rejects.toThrow('500')
86
- })
87
-
88
- it('uses custom HTTP method', async () => {
89
- const fetchMock = vi.fn().mockResolvedValue({ ok: true })
90
- vi.stubGlobal('fetch', fetchMock)
91
- const ctx = makeCtx()
92
- await executeAction({ type: 'webhook', url: 'https://x.com', method: 'PUT' }, ctx)
93
- expect(fetchMock.mock.calls[0][1].method).toBe('PUT')
94
- })
95
- })
96
-
97
- // ── send_mail ─────────────────────────────────────────────────────────────────
98
-
99
- vi.mock('../../email', () => ({
100
- sendAutomationMail: vi.fn().mockResolvedValue(undefined),
101
- }))
102
-
103
- describe('send_mail executor', () => {
104
- beforeEach(async () => {
105
- const { sendAutomationMail } = await import('../../email')
106
- vi.mocked(sendAutomationMail).mockClear()
107
- })
108
-
109
- it('interpolates to/subject/body and calls sendAutomationMail', async () => {
110
- const { sendAutomationMail } = await import('../../email')
111
- const ctx = makeCtx({ entry: { id: '1', email: 'user@example.com', title: 'Hello' } })
112
-
113
- await executeAction(
114
- {
115
- type: 'send_mail',
116
- to: '{{email}}',
117
- subject_template: 'Re: {{title}}',
118
- body_template: 'Your entry {{title}} was created.',
119
- },
120
- ctx,
121
- )
122
-
123
- expect(sendAutomationMail).toHaveBeenCalledWith(
124
- expect.objectContaining({
125
- to: 'user@example.com',
126
- subject: 'Re: Hello',
127
- body: 'Your entry Hello was created.',
128
- }),
129
- )
130
- })
131
-
132
- it('substitutes default value [missing] and logs a warning when concrete data is absent', async () => {
133
- const { sendAutomationMail } = await import('../../email')
134
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
135
- const ctx = makeCtx({ entry: { id: '1' } })
136
-
137
- await executeAction(
138
- {
139
- type: 'send_mail',
140
- to: '{{email}}',
141
- subject_template: 'Re: {{title}}',
142
- body_template: 'Content: {{desc}}',
143
- },
144
- ctx,
145
- )
146
-
147
- expect(sendAutomationMail).toHaveBeenCalledWith(
148
- expect.objectContaining({
149
- to: '[missing]',
150
- subject: 'Re: [missing]',
151
- body: 'Content: [missing]',
152
- }),
153
- )
154
- expect(warnSpy).toHaveBeenCalledWith(
155
- expect.stringContaining('missing concrete data'),
156
- ['email', 'title', 'desc'],
157
- )
158
- })
159
-
160
- it('skips execution gracefully and logs an error without calling sendAutomationMail if apiKey is missing', async () => {
161
- const { sendAutomationMail } = await import('../../email')
162
- const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
163
- const ctx = makeCtx({ env: {} }) // missing email/resend api keys
164
-
165
- await executeAction(
166
- {
167
- type: 'send_mail',
168
- to: 'user@beech.io',
169
- subject_template: 'Hello',
170
- body_template: 'World',
171
- },
172
- ctx,
173
- )
174
-
175
- expect(sendAutomationMail).not.toHaveBeenCalled()
176
- expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Execution skipped'))
177
- })
178
-
179
- it('logs error and rethrows when sendAutomationMail fails', async () => {
180
- const { sendAutomationMail } = await import('../../email')
181
- vi.mocked(sendAutomationMail).mockRejectedValueOnce(new Error('provider error'))
182
- const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
183
- const ctx = makeCtx()
184
-
185
- await expect(
186
- executeAction(
187
- {
188
- type: 'send_mail',
189
- to: 'test@beech.io',
190
- subject_template: 'Hello',
191
- body_template: 'World',
192
- },
193
- ctx,
194
- ),
195
- ).rejects.toThrow('provider error')
196
-
197
- expect(errSpy).toHaveBeenCalledWith(
198
- expect.stringContaining('failed to send automation email'),
199
- expect.any(Error),
200
- )
201
- })
202
- })
203
-
204
- // ── edit_field ────────────────────────────────────────────────────────────────
205
-
206
- describe('edit_field executor', () => {
207
- it('calls repository.update with interpolated string value', async () => {
208
- const ctx = makeCtx({ entry: { id: 'entry-1', title: 'Old Title' } })
209
- await executeAction({ type: 'edit_field', field: 'summary', value: 'Based on {{title}}' }, ctx)
210
- expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { summary: 'Based on Old Title' })
211
- })
212
-
213
- it('calls repository.update with raw non-string value', async () => {
214
- const ctx = makeCtx({ entry: { id: 'entry-1', title: 'T' } })
215
- await executeAction({ type: 'edit_field', field: 'count', value: 42 }, ctx)
216
- expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { count: 42 })
217
- })
218
-
219
- it('throws when entry.id is missing', async () => {
220
- const ctx = makeCtx({ entry: { title: 'No ID' } })
221
- await expect(executeAction({ type: 'edit_field', field: 'x', value: 'y' }, ctx)).rejects.toThrow('entry.id missing')
222
- })
223
- })
224
-
225
- // ── create_entry ──────────────────────────────────────────────────────────────
226
-
227
- describe('create_entry executor', () => {
228
- it('creates a new entry with field_map values from trigger entry', async () => {
229
- const ctx = makeCtx({ entry: { id: 'e1', author: 'Alice' } })
230
- await executeAction(
231
- { type: 'create_entry', seed_slug: 'comments', field_map: { writer: 'author', fixed: 'literal-value' } },
232
- ctx,
233
- )
234
- expect(ctx.idGenerator.uuid).toHaveBeenCalled()
235
- expect(ctx.repository.create).toHaveBeenCalledWith(
236
- expect.anything(),
237
- 'new-id-123',
238
- 'new-id-123',
239
- 'draft',
240
- { writer: 'Alice', fixed: 'literal-value' },
241
- )
242
- })
243
-
244
- it('uses literal string when source field is not in entry', async () => {
245
- const ctx = makeCtx({ entry: { id: 'e1' } })
246
- await executeAction(
247
- { type: 'create_entry', seed_slug: 'comments', field_map: { tag: 'missing-field' } },
248
- ctx,
249
- )
250
- expect(ctx.repository.create).toHaveBeenCalledWith(
251
- expect.anything(), 'new-id-123', 'new-id-123', 'draft',
252
- { tag: 'missing-field' },
253
- )
254
- })
255
-
256
- it('throws when seed not found', async () => {
257
- const ctx = makeCtx({ getSeed: vi.fn().mockReturnValue(null) })
258
- await expect(
259
- executeAction({ type: 'create_entry', seed_slug: 'unknown', field_map: {} }, ctx),
260
- ).rejects.toThrow('unknown seed unknown')
261
- })
262
-
263
- it('throws error for unknown action type to cover exhaustive default check', async () => {
264
- const ctx = makeCtx()
265
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
- await expect(executeAction({ type: 'unknown_custom_action' } as any, ctx)).rejects.toThrow('unknown action type')
267
- })
268
- })
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { executeAction } from '../action-executors'
7
+ import type { ActionContext } from '../action-executors'
8
+ import type { ContentRepository, Seed, IIdGenerator } from '@beechcms/core'
9
+ import { deleteAllMessages, listMessages, waitForMessage, getMessageHtml, getMailpitPort } from '../../../../test/helpers/mailpit-client'
10
+ import { newBucket, waitForRequest } from '../../../../test/helpers/webhook-tester-client'
11
+
12
+ // ── Shared mock context factory ───────────────────────────────────────────────
13
+
14
+ function makeCtx(overrides: Partial<ActionContext> = {}): ActionContext {
15
+ const { entry: entryOverride, context: contextOverride, ...rest } = overrides
16
+ const entry: Record<string, unknown> = entryOverride ?? { id: 'entry-1', title: 'Test Entry', status: 'published' }
17
+ const context = contextOverride ?? {
18
+ triggerEntry: entry,
19
+ lookup(parsed: import('../template-grammar').ParsedKey, onMissing?: (f: string) => void) {
20
+ if (parsed.kind === 'simple') {
21
+ const val = entry[parsed.path]
22
+ if (val == null && onMissing) onMissing(parsed.path)
23
+ return val
24
+ }
25
+ if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.field) {
26
+ return entry[parsed.field]
27
+ }
28
+ return undefined
29
+ },
30
+ }
31
+ return {
32
+ entry,
33
+ env: { RESEND_API_KEY: 'test-key', EMAIL_FROM: 'test@example.com' },
34
+ repository: {
35
+ update: vi.fn().mockResolvedValue(undefined),
36
+ create: vi.fn().mockResolvedValue(undefined),
37
+ } as unknown as ContentRepository,
38
+ getSeed: vi.fn().mockReturnValue({ branches: [] } as unknown as Seed),
39
+ seed: { slug: 'posts', branches: [] } as unknown as Seed,
40
+ idGenerator: { uuid: vi.fn().mockReturnValue('new-id-123') } as unknown as IIdGenerator,
41
+ context,
42
+ variables: {},
43
+ ...rest,
44
+ }
45
+ }
46
+
47
+ // ── webhook ───────────────────────────────────────────────────────────────────
48
+
49
+ describe('webhook executor', () => {
50
+ it('POSTs to URL with interpolated body_template', async () => {
51
+ const { url, uuid } = await newBucket()
52
+ const ctx = makeCtx()
53
+ await executeAction({ type: 'webhook', url, body_template: '{"id":"{{id}}","title":"{{title}}"}' }, ctx)
54
+ const req = await waitForRequest(uuid)
55
+ expect(req.method).toBe('POST')
56
+ expect(JSON.parse(req.body)).toMatchObject({ id: 'entry-1', title: 'Test Entry' })
57
+ })
58
+
59
+ it('interpolates body_template with entry values', async () => {
60
+ const { url, uuid } = await newBucket()
61
+ const ctx = makeCtx()
62
+ await executeAction({ type: 'webhook', url, body_template: '{"t":"{{title}}"}' }, ctx)
63
+ const req = await waitForRequest(uuid)
64
+ expect(req.body).toBe('{"t":"Test Entry"}')
65
+ })
66
+
67
+ it('merges custom headers', async () => {
68
+ const { url, uuid } = await newBucket()
69
+ const ctx = makeCtx()
70
+ await executeAction({ type: 'webhook', url, headers: { 'X-Token': 'abc' } }, ctx)
71
+ const req = await waitForRequest(uuid)
72
+ expect(req.headers['x-token']).toBe('abc')
73
+ })
74
+
75
+ it('throws when webhook endpoint is unreachable', async () => {
76
+ const ctx = makeCtx()
77
+ await expect(executeAction({ type: 'webhook', url: 'http://localhost:19999/nope' }, ctx)).rejects.toThrow()
78
+ })
79
+
80
+ it('uses custom HTTP method', async () => {
81
+ const { url, uuid } = await newBucket()
82
+ const ctx = makeCtx()
83
+ await executeAction({ type: 'webhook', url, method: 'PUT' }, ctx)
84
+ const req = await waitForRequest(uuid)
85
+ expect(req.method).toBe('PUT')
86
+ })
87
+ })
88
+
89
+ // ── send_mail ─────────────────────────────────────────────────────────────────
90
+
91
+ describe('send_mail executor', () => {
92
+ beforeEach(() => deleteAllMessages())
93
+
94
+ it('interpolates to/subject/body and delivers to Mailpit', async () => {
95
+ const ctx = makeCtx({
96
+ entry: { id: '1', email: 'user@example.com', title: 'Hello' },
97
+ env: { EMAIL_PROVIDER: 'smtp', SMTP_HOST: 'localhost', SMTP_PORT: getMailpitPort(), EMAIL_FROM: 'Beech <test@beech.local>' },
98
+ })
99
+
100
+ await executeAction(
101
+ {
102
+ type: 'send_mail',
103
+ to: '{{email}}',
104
+ subject_template: 'Re: {{title}}',
105
+ body_template: 'Your entry {{title}} was created.',
106
+ },
107
+ ctx,
108
+ )
109
+
110
+ const msg = await waitForMessage(m => m.To.some(t => t.Address === 'user@example.com'))
111
+ expect(msg.Subject).toBe('Re: Hello')
112
+ const html = await getMessageHtml(msg.ID)
113
+ expect(html).toContain('Hello')
114
+ })
115
+
116
+ it('substitutes [missing] for absent fields and logs a warning', async () => {
117
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
118
+ // email is present (needed for a valid To address) but title and desc are absent
119
+ const ctx = makeCtx({
120
+ entry: { id: '1', email: 'warn-test@example.com' },
121
+ env: { EMAIL_PROVIDER: 'smtp', SMTP_HOST: 'localhost', SMTP_PORT: getMailpitPort(), EMAIL_FROM: 'Beech <test@beech.local>' },
122
+ })
123
+
124
+ await executeAction(
125
+ {
126
+ type: 'send_mail',
127
+ to: '{{email}}',
128
+ subject_template: 'Re: {{title}}',
129
+ body_template: 'Content: {{desc}}',
130
+ },
131
+ ctx,
132
+ )
133
+
134
+ expect(warnSpy).toHaveBeenCalledWith(
135
+ expect.stringContaining('missing concrete data'),
136
+ expect.arrayContaining(['title', 'desc']),
137
+ )
138
+ const msg = await waitForMessage(m => m.Subject === 'Re: [missing]')
139
+ expect(msg.Subject).toBe('Re: [missing]')
140
+ const html = await getMessageHtml(msg.ID)
141
+ expect(html).toContain('[missing]')
142
+ warnSpy.mockRestore()
143
+ })
144
+
145
+ it('skips execution and logs an error when provider is resend and apiKey is missing', async () => {
146
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
147
+ const ctx = makeCtx({ env: {} })
148
+
149
+ await executeAction(
150
+ {
151
+ type: 'send_mail',
152
+ to: 'user@beech.io',
153
+ subject_template: 'Hello',
154
+ body_template: 'World',
155
+ },
156
+ ctx,
157
+ )
158
+
159
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Execution skipped'))
160
+ const msgs = await listMessages()
161
+ expect(msgs).toHaveLength(0)
162
+ errSpy.mockRestore()
163
+ })
164
+
165
+ it('logs error and rethrows when SMTP provider is unreachable', async () => {
166
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
167
+ const ctx = makeCtx({
168
+ env: { EMAIL_PROVIDER: 'smtp', SMTP_HOST: 'localhost', SMTP_PORT: '19999', EMAIL_FROM: 'Beech <test@beech.local>' },
169
+ })
170
+
171
+ await expect(
172
+ executeAction(
173
+ {
174
+ type: 'send_mail',
175
+ to: 'test@beech.io',
176
+ subject_template: 'Hello',
177
+ body_template: 'World',
178
+ },
179
+ ctx,
180
+ ),
181
+ ).rejects.toThrow()
182
+
183
+ expect(errSpy).toHaveBeenCalledWith(
184
+ expect.stringContaining('failed to send automation email'),
185
+ expect.any(Error),
186
+ )
187
+ errSpy.mockRestore()
188
+ })
189
+ })
190
+
191
+ // ── edit_field ────────────────────────────────────────────────────────────────
192
+
193
+ describe('edit_field executor', () => {
194
+ it('calls repository.update with interpolated string value', async () => {
195
+ const ctx = makeCtx({ entry: { id: 'entry-1', title: 'Old Title' } })
196
+ await executeAction({ type: 'edit_field', field: 'summary', value: 'Based on {{title}}' }, ctx)
197
+ expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { summary: 'Based on Old Title' })
198
+ })
199
+
200
+ it('calls repository.update with raw non-string value', async () => {
201
+ const ctx = makeCtx({ entry: { id: 'entry-1', title: 'T' } })
202
+ await executeAction({ type: 'edit_field', field: 'count', value: 42 }, ctx)
203
+ expect(ctx.repository.update).toHaveBeenCalledWith(ctx.seed, 'entry-1', { count: 42 })
204
+ })
205
+
206
+ it('throws when entry.id is missing', async () => {
207
+ const ctx = makeCtx({ entry: { title: 'No ID' } })
208
+ await expect(executeAction({ type: 'edit_field', field: 'x', value: 'y' }, ctx)).rejects.toThrow('entry.id missing')
209
+ })
210
+ })
211
+
212
+ // ── create_entry ──────────────────────────────────────────────────────────────
213
+
214
+ describe('create_entry executor', () => {
215
+ it('creates a new entry with field_map values from trigger entry', async () => {
216
+ const ctx = makeCtx({ entry: { id: 'e1', author: 'Alice' } })
217
+ await executeAction(
218
+ { type: 'create_entry', seed_slug: 'comments', field_map: { writer: 'author', fixed: 'literal-value' } },
219
+ ctx,
220
+ )
221
+ expect(ctx.idGenerator.uuid).toHaveBeenCalled()
222
+ expect(ctx.repository.create).toHaveBeenCalledWith(
223
+ expect.anything(),
224
+ 'new-id-123',
225
+ 'new-id-123',
226
+ 'draft',
227
+ { writer: 'Alice', fixed: 'literal-value' },
228
+ )
229
+ })
230
+
231
+ it('uses literal string when source field is not in entry', async () => {
232
+ const ctx = makeCtx({ entry: { id: 'e1' } })
233
+ await executeAction(
234
+ { type: 'create_entry', seed_slug: 'comments', field_map: { tag: 'missing-field' } },
235
+ ctx,
236
+ )
237
+ expect(ctx.repository.create).toHaveBeenCalledWith(
238
+ expect.anything(), 'new-id-123', 'new-id-123', 'draft',
239
+ { tag: 'missing-field' },
240
+ )
241
+ })
242
+
243
+ it('throws when seed not found', async () => {
244
+ const ctx = makeCtx({ getSeed: vi.fn().mockReturnValue(null) })
245
+ await expect(
246
+ executeAction({ type: 'create_entry', seed_slug: 'unknown', field_map: {} }, ctx),
247
+ ).rejects.toThrow('unknown seed unknown')
248
+ })
249
+
250
+ it('throws error for unknown action type to cover exhaustive default check', async () => {
251
+ const ctx = makeCtx()
252
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
+ await expect(executeAction({ type: 'unknown_custom_action' } as any, ctx)).rejects.toThrow('unknown action type')
254
+ })
255
+ })