@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,134 +1,224 @@
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
- })
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 } from 'vitest'
6
+ import {
7
+ createAutomationSchema,
8
+ updateAutomationSchema,
9
+ toggleAutomationSchema,
10
+ } from '../automations.schema'
11
+
12
+ const validWebhookAction = { type: 'webhook' as const, url: 'https://example.com/hook', body_template: '{}' }
13
+
14
+ const minimalValid = {
15
+ seed_slug: 'posts',
16
+ name: 'notify-on-create',
17
+ triggers: [{ event: 'create' as const }],
18
+ actions: [validWebhookAction],
19
+ }
20
+
21
+ describe('createAutomationSchema', () => {
22
+ it('accepts minimal valid create payload', () => {
23
+ expect(createAutomationSchema.safeParse(minimalValid).success).toBe(true)
24
+ })
25
+
26
+ it('rejects cron trigger without cron expression', () => {
27
+ const result = createAutomationSchema.safeParse({
28
+ ...minimalValid,
29
+ triggers: [{ event: 'cron' }],
30
+ })
31
+ expect(result.success).toBe(false)
32
+ })
33
+
34
+ it('accepts cron trigger with cron expression', () => {
35
+ const result = createAutomationSchema.safeParse({
36
+ ...minimalValid,
37
+ triggers: [{ event: 'cron', cron: '0 * * * *' }],
38
+ })
39
+ expect(result.success).toBe(true)
40
+ })
41
+
42
+ it('rejects duplicate trigger events', () => {
43
+ const result = createAutomationSchema.safeParse({
44
+ ...minimalValid,
45
+ triggers: [{ event: 'create' }, { event: 'create' }],
46
+ })
47
+ expect(result.success).toBe(false)
48
+ })
49
+
50
+ it('accepts multiple distinct triggers', () => {
51
+ const result = createAutomationSchema.safeParse({
52
+ ...minimalValid,
53
+ triggers: [{ event: 'create' }, { event: 'update' }],
54
+ })
55
+ expect(result.success).toBe(true)
56
+ })
57
+
58
+ it('rejects empty actions array', () => {
59
+ const result = createAutomationSchema.safeParse({ ...minimalValid, actions: [] })
60
+ expect(result.success).toBe(false)
61
+ })
62
+
63
+ it('rejects unknown action type', () => {
64
+ const result = createAutomationSchema.safeParse({
65
+ ...minimalValid,
66
+ actions: [{ type: 'unknown_type', url: 'https://example.com' }],
67
+ })
68
+ expect(result.success).toBe(false)
69
+ })
70
+
71
+ it('rejects webhook with non-url', () => {
72
+ const result = createAutomationSchema.safeParse({
73
+ ...minimalValid,
74
+ actions: [{ type: 'webhook', url: 'not-a-url', body_template: '{}' }],
75
+ })
76
+ expect(result.success).toBe(false)
77
+ })
78
+
79
+ it('rejects webhook with HTTP url (not HTTPS)', () => {
80
+ const result = createAutomationSchema.safeParse({
81
+ ...minimalValid,
82
+ actions: [{ type: 'webhook', url: 'http://example.com', body_template: '{}' }],
83
+ })
84
+ expect(result.success).toBe(false)
85
+ const issues = result.error?.issues ?? []
86
+ expect(issues.some((i) => i.message === 'automations.editor.errors.webhookHttpsRequired')).toBe(true)
87
+ })
88
+
89
+ it('rejects webhook with localhost url', () => {
90
+ const result = createAutomationSchema.safeParse({
91
+ ...minimalValid,
92
+ actions: [{ type: 'webhook', url: 'https://localhost/x', body_template: '{}' }],
93
+ })
94
+ expect(result.success).toBe(false)
95
+ const issues = result.error?.issues ?? []
96
+ expect(issues.some((i) => i.message === 'automations.editor.errors.webhookPrivateHostBlocked')).toBe(true)
97
+ })
98
+
99
+ it('rejects webhook with 127.0.0.1', () => {
100
+ const result = createAutomationSchema.safeParse({
101
+ ...minimalValid,
102
+ actions: [{ type: 'webhook', url: 'https://127.0.0.1/x', body_template: '{}' }],
103
+ })
104
+ expect(result.success).toBe(false)
105
+ })
106
+
107
+ it('rejects webhook with AWS metadata IP (169.254.169.254)', () => {
108
+ const result = createAutomationSchema.safeParse({
109
+ ...minimalValid,
110
+ actions: [{ type: 'webhook', url: 'https://169.254.169.254/x', body_template: '{}' }],
111
+ })
112
+ expect(result.success).toBe(false)
113
+ })
114
+
115
+ it('rejects webhook with 10.x.x.x private IP', () => {
116
+ const result = createAutomationSchema.safeParse({
117
+ ...minimalValid,
118
+ actions: [{ type: 'webhook', url: 'https://10.0.0.5/x', body_template: '{}' }],
119
+ })
120
+ expect(result.success).toBe(false)
121
+ })
122
+
123
+ it('rejects webhook with 192.168.x.x private IP', () => {
124
+ const result = createAutomationSchema.safeParse({
125
+ ...minimalValid,
126
+ actions: [{ type: 'webhook', url: 'https://192.168.1.1/x', body_template: '{}' }],
127
+ })
128
+ expect(result.success).toBe(false)
129
+ })
130
+
131
+ it('rejects webhook with 172.16.x.x private IP', () => {
132
+ const result = createAutomationSchema.safeParse({
133
+ ...minimalValid,
134
+ actions: [{ type: 'webhook', url: 'https://172.16.0.1/x', body_template: '{}' }],
135
+ })
136
+ expect(result.success).toBe(false)
137
+ })
138
+
139
+ it('accepts webhook with 172.32.x.x (outside private range)', () => {
140
+ const result = createAutomationSchema.safeParse({
141
+ ...minimalValid,
142
+ actions: [{ type: 'webhook', url: 'https://172.32.0.1/x', body_template: '{}' }],
143
+ })
144
+ expect(result.success).toBe(true)
145
+ })
146
+
147
+ it('rejects webhook without body_template', () => {
148
+ const result = createAutomationSchema.safeParse({
149
+ ...minimalValid,
150
+ actions: [{ type: 'webhook', url: 'https://example.com/x' }],
151
+ })
152
+ expect(result.success).toBe(false)
153
+ const issues = result.error?.issues ?? []
154
+ expect(issues.some((i) => i.message === 'automations.editor.errors.bodyRequired')).toBe(true)
155
+ })
156
+
157
+ it('accepts webhook with valid HTTPS public url and body_template', () => {
158
+ const result = createAutomationSchema.safeParse({
159
+ ...minimalValid,
160
+ actions: [{ type: 'webhook', url: 'https://example.com/x', body_template: '{}' }],
161
+ })
162
+ expect(result.success).toBe(true)
163
+ })
164
+
165
+ it('rejects send_mail with invalid email', () => {
166
+ const result = createAutomationSchema.safeParse({
167
+ ...minimalValid,
168
+ actions: [{ type: 'send_mail', to: 'not-an-email', subject_template: 'hi', body_template: 'body' }],
169
+ })
170
+ expect(result.success).toBe(false)
171
+ })
172
+
173
+ it('accepts trigger_conditions as null', () => {
174
+ const result = createAutomationSchema.safeParse({
175
+ ...minimalValid,
176
+ trigger_conditions: null,
177
+ })
178
+
179
+ expect(result.success).toBe(true)
180
+ })
181
+
182
+ it('accepts trigger_conditions as WhenNode', () => {
183
+ const result = createAutomationSchema.safeParse({
184
+ ...minimalValid,
185
+ trigger_conditions: {
186
+ kind: 'group',
187
+ op: 'AND',
188
+ children: [
189
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'published' } },
190
+ ],
191
+ },
192
+ })
193
+ expect(result.success).toBe(true)
194
+ })
195
+ })
196
+
197
+ describe('updateAutomationSchema', () => {
198
+ it('accepts empty object', () => {
199
+ expect(updateAutomationSchema.safeParse({}).success).toBe(true)
200
+ })
201
+
202
+ it('accepts name-only without requiring triggers', () => {
203
+ const result = updateAutomationSchema.safeParse({ name: 'renamed' })
204
+ expect(result.success).toBe(true)
205
+ })
206
+ })
207
+
208
+ describe('toggleAutomationSchema', () => {
209
+ it('accepts { enabled: true }', () => {
210
+ expect(toggleAutomationSchema.safeParse({ enabled: true }).success).toBe(true)
211
+ })
212
+
213
+ it('accepts { enabled: false }', () => {
214
+ expect(toggleAutomationSchema.safeParse({ enabled: false }).success).toBe(true)
215
+ })
216
+
217
+ it('rejects missing enabled', () => {
218
+ expect(toggleAutomationSchema.safeParse({}).success).toBe(false)
219
+ })
220
+
221
+ it('rejects non-boolean enabled', () => {
222
+ expect(toggleAutomationSchema.safeParse({ enabled: 'true' }).success).toBe(false)
223
+ })
224
+ })
@@ -1,122 +1,126 @@
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
- })
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 } from 'vitest'
6
+ import type { Automation } from '@beechcms/core'
7
+ import { resolveAutomationContext, deriveEntryContext } from '../context-resolver'
8
+ import { parseTemplateKey } from '../template-grammar'
9
+
10
+ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
11
+ return {
12
+ id: 'auto-1',
13
+ seed_slug: 'orders',
14
+ name: 'test',
15
+ enabled: true,
16
+ triggers: [{ event: 'cron' as const, cron: '* * * * *' }],
17
+ trigger_conditions: null,
18
+ actions: [],
19
+ created_at: 0,
20
+ updated_at: 0,
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ describe('resolveAutomationContext', () => {
26
+ const triggerEntry = { id: 'e1', email: 'alice@example.com', product_id: 'p1' }
27
+ const batchEntries = [
28
+ { id: 'e1', total: 10, email: 'alice@example.com' },
29
+ { id: 'e2', total: 20, email: 'bob@example.com' },
30
+ { id: 'e3', total: 30, email: 'carol@example.com' },
31
+ ]
32
+
33
+ it('this scope resolves to triggering entry field', async () => {
34
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
35
+ expect(ctx.lookup(parseTemplateKey('this:email')!)).toBe('alice@example.com')
36
+ })
37
+
38
+ it('this. dot notation resolves to triggering entry field', async () => {
39
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
40
+ expect(ctx.lookup(parseTemplateKey('email')!)).toBe('alice@example.com')
41
+ expect(ctx.lookup(parseTemplateKey('this:product_id')!)).toBe('p1')
42
+ })
43
+
44
+ it('batch:all:count matches batchEntries length', async () => {
45
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, batchEntries)
46
+ expect(ctx.lookup(parseTemplateKey('batch:all:count')!)).toBe(3)
47
+ })
48
+
49
+ it('unknown scope → undefined + onMissing fires', async () => {
50
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, [triggerEntry])
51
+ const missing: string[] = []
52
+ const val = ctx.lookup(parseTemplateKey('unknownseed:lastone:email')!, (f) => missing.push(f))
53
+ expect(val).toBeUndefined()
54
+ expect(missing).toHaveLength(1)
55
+ expect(missing[0]).toBe('unknownseed')
56
+ })
57
+
58
+ it('pluck truncates to 100 entries and appends truncation marker', async () => {
59
+ const rows = Array.from({ length: 150 }, (_, i) => ({ email: `u${i}@example.com` }))
60
+ const ctx = await resolveAutomationContext(makeAutomation(), triggerEntry, rows)
61
+ const key = parseTemplateKey('batch:all:pluck:email')!
62
+ const result = ctx.lookup(key) as string
63
+ const values = result.split(', ')
64
+ expect(result.endsWith(' ')).toBe(true)
65
+ expect(values.length).toBe(100)
66
+ })
67
+
68
+ it('sum on non-numeric field values returns 0 and fires onMissing for NaN', async () => {
69
+ const ctx = await resolveAutomationContext(
70
+ makeAutomation(),
71
+ triggerEntry,
72
+ [{ id: 'e1', amount: 'not-a-number' }, { id: 'e2', amount: 'also-not' }],
73
+ )
74
+ const missing: string[] = []
75
+ const result = ctx.lookup(parseTemplateKey('batch:all:sum:amount')!, (f) => missing.push(f))
76
+ expect(result).toBe(0)
77
+ expect(missing.length).toBeGreaterThan(0)
78
+ })
79
+
80
+ describe('aggregates on batch', () => {
81
+ const rows = [
82
+ { id: 'e1', total: 10 },
83
+ { id: 'e2', total: 20 },
84
+ { id: 'e3', total: 30 },
85
+ ]
86
+
87
+ async function makeCtx(batchRows: Record<string, unknown>[]) {
88
+ return resolveAutomationContext(makeAutomation(), batchRows[0] ?? null, batchRows)
89
+ }
90
+
91
+ it('count', async () => {
92
+ const ctx = await makeCtx(rows)
93
+ expect(ctx.lookup(parseTemplateKey('batch:all:count')!)).toBe(3)
94
+ })
95
+
96
+ it('sum', async () => {
97
+ const ctx = await makeCtx(rows)
98
+ expect(ctx.lookup(parseTemplateKey('batch:all:sum:total')!)).toBe(60)
99
+ })
100
+
101
+ it('avg', async () => {
102
+ const ctx = await makeCtx(rows)
103
+ expect(ctx.lookup(parseTemplateKey('batch:all:avg:total')!)).toBe(20)
104
+ })
105
+
106
+ it('min', async () => {
107
+ const ctx = await makeCtx(rows)
108
+ expect(ctx.lookup(parseTemplateKey('batch:all:min:total')!)).toBe(10)
109
+ })
110
+
111
+ it('max', async () => {
112
+ const ctx = await makeCtx(rows)
113
+ expect(ctx.lookup(parseTemplateKey('batch:all:max:total')!)).toBe(30)
114
+ })
115
+ })
116
+ })
117
+
118
+ describe('deriveEntryContext', () => {
119
+ it('this scope returns new entry, batch scope delegates to base', async () => {
120
+ const batchRows = [{ id: 'e1', val: 1 }, { id: 'e2', val: 2 }]
121
+ const base = await resolveAutomationContext(makeAutomation(), batchRows[0] ?? null, batchRows)
122
+ const derived = deriveEntryContext(base, { id: 'e2', val: 2, email: 'derived@example.com' })
123
+ expect(derived.lookup(parseTemplateKey('this:email')!)).toBe('derived@example.com')
124
+ expect(derived.lookup(parseTemplateKey('batch:all:count')!)).toBe(2)
125
+ })
126
+ })