@beechcms/api 0.6.0-preview.1 → 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 (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,274 +1,274 @@
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 { WhenNode } from '@beechcms/core'
7
- import { evaluateWhen } from '../when-evaluator'
8
- import type { ResolvedContext } from '../context-resolver'
9
- import { parseTemplateKey } from '../template-grammar'
10
-
11
- // ---------------------------------------------------------------------------
12
- // Minimal ResolvedContext builder for testing
13
- // ---------------------------------------------------------------------------
14
-
15
- function makeContext(entry: Record<string, unknown>): ResolvedContext {
16
- return {
17
- triggerEntry: entry,
18
- lookup(parsed, onMissing) {
19
- if (!parsed) return undefined
20
- if (parsed.kind === 'simple') {
21
- const parts = parsed.path.split('.')
22
- let cur: unknown = entry
23
- for (const p of parts) {
24
- cur = (cur as Record<string, unknown>)?.[p]
25
- }
26
- if (cur === undefined && onMissing) onMissing(parsed.path)
27
- return cur
28
- }
29
- if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.op === 'field' && parsed.field) {
30
- return (entry as Record<string, unknown>)[parsed.field]
31
- }
32
- return undefined
33
- },
34
- }
35
- }
36
-
37
- const entry = { status: 'paid', total: 150, name: 'Alice', tag: 'gold' }
38
- const ctx = makeContext(entry)
39
-
40
- // ---------------------------------------------------------------------------
41
- // null / empty → true
42
- // ---------------------------------------------------------------------------
43
-
44
- describe('evaluateWhen — null/empty', () => {
45
- it('null node returns true', () => {
46
- expect(evaluateWhen(null, ctx)).toBe(true)
47
- })
48
-
49
- it('empty AND group returns true', () => {
50
- const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
51
- expect(evaluateWhen(node, ctx)).toBe(true)
52
- })
53
-
54
- it('empty OR group returns false', () => {
55
- const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
56
- expect(evaluateWhen(node, ctx)).toBe(false)
57
- })
58
- })
59
-
60
- // ---------------------------------------------------------------------------
61
- // Basic predicates
62
- // ---------------------------------------------------------------------------
63
-
64
- describe('evaluateWhen — predicates', () => {
65
- it('eq literal', () => {
66
- const node: WhenNode = {
67
- kind: 'predicate',
68
- left: { kind: 'ref', key: 'this.status' },
69
- op: 'eq',
70
- right: { kind: 'literal', value: 'paid' },
71
- }
72
- expect(evaluateWhen(node, ctx)).toBe(true)
73
- })
74
-
75
- it('eq literal fails', () => {
76
- const node: WhenNode = {
77
- kind: 'predicate',
78
- left: { kind: 'ref', key: 'this.status' },
79
- op: 'eq',
80
- right: { kind: 'literal', value: 'unpaid' },
81
- }
82
- expect(evaluateWhen(node, ctx)).toBe(false)
83
- })
84
-
85
- it('gt numeric', () => {
86
- const node: WhenNode = {
87
- kind: 'predicate',
88
- left: { kind: 'ref', key: 'this.total' },
89
- op: 'gt',
90
- right: { kind: 'literal', value: 100 },
91
- }
92
- expect(evaluateWhen(node, ctx)).toBe(true)
93
- })
94
-
95
- it('lte numeric', () => {
96
- const node: WhenNode = {
97
- kind: 'predicate',
98
- left: { kind: 'ref', key: 'this.total' },
99
- op: 'lte',
100
- right: { kind: 'literal', value: 150 },
101
- }
102
- expect(evaluateWhen(node, ctx)).toBe(true)
103
- })
104
-
105
- it('contains string', () => {
106
- const node: WhenNode = {
107
- kind: 'predicate',
108
- left: { kind: 'ref', key: 'this.name' },
109
- op: 'contains',
110
- right: { kind: 'literal', value: 'lic' },
111
- }
112
- expect(evaluateWhen(node, ctx)).toBe(true)
113
- })
114
-
115
- it('startswith', () => {
116
- const node: WhenNode = {
117
- kind: 'predicate',
118
- left: { kind: 'ref', key: 'this.name' },
119
- op: 'startswith',
120
- right: { kind: 'literal', value: 'Ali' },
121
- }
122
- expect(evaluateWhen(node, ctx)).toBe(true)
123
- })
124
-
125
- it('isempty — non-empty field', () => {
126
- const node: WhenNode = {
127
- kind: 'predicate',
128
- left: { kind: 'ref', key: 'this.status' },
129
- op: 'isempty',
130
- }
131
- expect(evaluateWhen(node, ctx)).toBe(false)
132
- })
133
-
134
- it('isnotempty — non-empty field', () => {
135
- const node: WhenNode = {
136
- kind: 'predicate',
137
- left: { kind: 'ref', key: 'this.status' },
138
- op: 'isnotempty',
139
- }
140
- expect(evaluateWhen(node, ctx)).toBe(true)
141
- })
142
-
143
- it('matches regex', () => {
144
- const node: WhenNode = {
145
- kind: 'predicate',
146
- left: { kind: 'ref', key: 'this.tag' },
147
- op: 'matches',
148
- right: { kind: 'literal', value: '^g' },
149
- }
150
- expect(evaluateWhen(node, ctx)).toBe(true)
151
- })
152
-
153
- it('literal-to-literal comparison', () => {
154
- const node: WhenNode = {
155
- kind: 'predicate',
156
- left: { kind: 'literal', value: 10 },
157
- op: 'lt',
158
- right: { kind: 'literal', value: 20 },
159
- }
160
- expect(evaluateWhen(node, ctx)).toBe(true)
161
- })
162
- })
163
-
164
- // ---------------------------------------------------------------------------
165
- // AND / OR groups with short-circuit
166
- // ---------------------------------------------------------------------------
167
-
168
- describe('evaluateWhen — groups', () => {
169
- it('AND group — all true', () => {
170
- const node: WhenNode = {
171
- kind: 'group',
172
- op: 'AND',
173
- children: [
174
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
175
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
176
- ],
177
- }
178
- expect(evaluateWhen(node, ctx)).toBe(true)
179
- })
180
-
181
- it('AND group — one false', () => {
182
- const node: WhenNode = {
183
- kind: 'group',
184
- op: 'AND',
185
- children: [
186
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
187
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
188
- ],
189
- }
190
- expect(evaluateWhen(node, ctx)).toBe(false)
191
- })
192
-
193
- it('OR group — first true short-circuits', () => {
194
- const node: WhenNode = {
195
- kind: 'group',
196
- op: 'OR',
197
- children: [
198
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
199
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
200
- ],
201
- }
202
- expect(evaluateWhen(node, ctx)).toBe(true)
203
- })
204
-
205
- it('negate wraps group result', () => {
206
- const node: WhenNode = {
207
- kind: 'group',
208
- op: 'AND',
209
- negate: true,
210
- children: [
211
- { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
212
- ],
213
- }
214
- expect(evaluateWhen(node, ctx)).toBe(false)
215
- })
216
-
217
- it('nested AND inside OR', () => {
218
- const node: WhenNode = {
219
- kind: 'group',
220
- op: 'AND',
221
- children: [
222
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
223
- {
224
- kind: 'group',
225
- op: 'OR',
226
- children: [
227
- { kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
228
- { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 5000 } },
229
- ],
230
- },
231
- ],
232
- }
233
- expect(evaluateWhen(node, ctx)).toBe(true)
234
- })
235
- })
236
-
237
- // ---------------------------------------------------------------------------
238
- // null handling
239
- // ---------------------------------------------------------------------------
240
-
241
- describe('evaluateWhen — null operands', () => {
242
- const emptyCtx = makeContext({})
243
-
244
- it('null == null → eq true', () => {
245
- const node: WhenNode = {
246
- kind: 'predicate',
247
- left: { kind: 'ref', key: 'this.missing' },
248
- op: 'eq',
249
- right: { kind: 'literal', value: null },
250
- }
251
- expect(evaluateWhen(node, emptyCtx)).toBe(true)
252
- })
253
-
254
- it('null isempty → true', () => {
255
- const node: WhenNode = {
256
- kind: 'predicate',
257
- left: { kind: 'ref', key: 'this.missing' },
258
- op: 'isempty',
259
- }
260
- expect(evaluateWhen(node, emptyCtx)).toBe(true)
261
- })
262
-
263
- it('null gt 0 → false (no exception)', () => {
264
- const node: WhenNode = {
265
- kind: 'predicate',
266
- left: { kind: 'ref', key: 'this.missing' },
267
- op: 'gt',
268
- right: { kind: 'literal', value: 0 },
269
- }
270
- expect(() => evaluateWhen(node, emptyCtx)).not.toThrow()
271
- expect(evaluateWhen(node, emptyCtx)).toBe(false)
272
- })
273
- })
274
-
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 { WhenNode } from '@beechcms/core'
7
+ import { evaluateWhen } from '../when-evaluator'
8
+ import type { ResolvedContext } from '../context-resolver'
9
+ import { parseTemplateKey } from '../template-grammar'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Minimal ResolvedContext builder for testing
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeContext(entry: Record<string, unknown>): ResolvedContext {
16
+ return {
17
+ triggerEntry: entry,
18
+ lookup(parsed, onMissing) {
19
+ if (!parsed) return undefined
20
+ if (parsed.kind === 'simple') {
21
+ const parts = parsed.path.split('.')
22
+ let cur: unknown = entry
23
+ for (const p of parts) {
24
+ cur = (cur as Record<string, unknown>)?.[p]
25
+ }
26
+ if (cur === undefined && onMissing) onMissing(parsed.path)
27
+ return cur
28
+ }
29
+ if (parsed.kind === 'scoped' && parsed.scope === 'this' && parsed.op === 'field' && parsed.field) {
30
+ return (entry as Record<string, unknown>)[parsed.field]
31
+ }
32
+ return undefined
33
+ },
34
+ }
35
+ }
36
+
37
+ const entry = { status: 'paid', total: 150, name: 'Alice', tag: 'gold' }
38
+ const ctx = makeContext(entry)
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // null / empty → true
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe('evaluateWhen — null/empty', () => {
45
+ it('null node returns true', () => {
46
+ expect(evaluateWhen(null, ctx)).toBe(true)
47
+ })
48
+
49
+ it('empty AND group returns true', () => {
50
+ const node: WhenNode = { kind: 'group', op: 'AND', children: [] }
51
+ expect(evaluateWhen(node, ctx)).toBe(true)
52
+ })
53
+
54
+ it('empty OR group returns false', () => {
55
+ const node: WhenNode = { kind: 'group', op: 'OR', children: [] }
56
+ expect(evaluateWhen(node, ctx)).toBe(false)
57
+ })
58
+ })
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Basic predicates
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('evaluateWhen — predicates', () => {
65
+ it('eq literal', () => {
66
+ const node: WhenNode = {
67
+ kind: 'predicate',
68
+ left: { kind: 'ref', key: 'this.status' },
69
+ op: 'eq',
70
+ right: { kind: 'literal', value: 'paid' },
71
+ }
72
+ expect(evaluateWhen(node, ctx)).toBe(true)
73
+ })
74
+
75
+ it('eq literal fails', () => {
76
+ const node: WhenNode = {
77
+ kind: 'predicate',
78
+ left: { kind: 'ref', key: 'this.status' },
79
+ op: 'eq',
80
+ right: { kind: 'literal', value: 'unpaid' },
81
+ }
82
+ expect(evaluateWhen(node, ctx)).toBe(false)
83
+ })
84
+
85
+ it('gt numeric', () => {
86
+ const node: WhenNode = {
87
+ kind: 'predicate',
88
+ left: { kind: 'ref', key: 'this.total' },
89
+ op: 'gt',
90
+ right: { kind: 'literal', value: 100 },
91
+ }
92
+ expect(evaluateWhen(node, ctx)).toBe(true)
93
+ })
94
+
95
+ it('lte numeric', () => {
96
+ const node: WhenNode = {
97
+ kind: 'predicate',
98
+ left: { kind: 'ref', key: 'this.total' },
99
+ op: 'lte',
100
+ right: { kind: 'literal', value: 150 },
101
+ }
102
+ expect(evaluateWhen(node, ctx)).toBe(true)
103
+ })
104
+
105
+ it('contains string', () => {
106
+ const node: WhenNode = {
107
+ kind: 'predicate',
108
+ left: { kind: 'ref', key: 'this.name' },
109
+ op: 'contains',
110
+ right: { kind: 'literal', value: 'lic' },
111
+ }
112
+ expect(evaluateWhen(node, ctx)).toBe(true)
113
+ })
114
+
115
+ it('startswith', () => {
116
+ const node: WhenNode = {
117
+ kind: 'predicate',
118
+ left: { kind: 'ref', key: 'this.name' },
119
+ op: 'startswith',
120
+ right: { kind: 'literal', value: 'Ali' },
121
+ }
122
+ expect(evaluateWhen(node, ctx)).toBe(true)
123
+ })
124
+
125
+ it('isempty — non-empty field', () => {
126
+ const node: WhenNode = {
127
+ kind: 'predicate',
128
+ left: { kind: 'ref', key: 'this.status' },
129
+ op: 'isempty',
130
+ }
131
+ expect(evaluateWhen(node, ctx)).toBe(false)
132
+ })
133
+
134
+ it('isnotempty — non-empty field', () => {
135
+ const node: WhenNode = {
136
+ kind: 'predicate',
137
+ left: { kind: 'ref', key: 'this.status' },
138
+ op: 'isnotempty',
139
+ }
140
+ expect(evaluateWhen(node, ctx)).toBe(true)
141
+ })
142
+
143
+ it('matches regex', () => {
144
+ const node: WhenNode = {
145
+ kind: 'predicate',
146
+ left: { kind: 'ref', key: 'this.tag' },
147
+ op: 'matches',
148
+ right: { kind: 'literal', value: '^g' },
149
+ }
150
+ expect(evaluateWhen(node, ctx)).toBe(true)
151
+ })
152
+
153
+ it('literal-to-literal comparison', () => {
154
+ const node: WhenNode = {
155
+ kind: 'predicate',
156
+ left: { kind: 'literal', value: 10 },
157
+ op: 'lt',
158
+ right: { kind: 'literal', value: 20 },
159
+ }
160
+ expect(evaluateWhen(node, ctx)).toBe(true)
161
+ })
162
+ })
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // AND / OR groups with short-circuit
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('evaluateWhen — groups', () => {
169
+ it('AND group — all true', () => {
170
+ const node: WhenNode = {
171
+ kind: 'group',
172
+ op: 'AND',
173
+ children: [
174
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
175
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
176
+ ],
177
+ }
178
+ expect(evaluateWhen(node, ctx)).toBe(true)
179
+ })
180
+
181
+ it('AND group — one false', () => {
182
+ const node: WhenNode = {
183
+ kind: 'group',
184
+ op: 'AND',
185
+ children: [
186
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
187
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
188
+ ],
189
+ }
190
+ expect(evaluateWhen(node, ctx)).toBe(false)
191
+ })
192
+
193
+ it('OR group — first true short-circuits', () => {
194
+ const node: WhenNode = {
195
+ kind: 'group',
196
+ op: 'OR',
197
+ children: [
198
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
199
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 200 } },
200
+ ],
201
+ }
202
+ expect(evaluateWhen(node, ctx)).toBe(true)
203
+ })
204
+
205
+ it('negate wraps group result', () => {
206
+ const node: WhenNode = {
207
+ kind: 'group',
208
+ op: 'AND',
209
+ negate: true,
210
+ children: [
211
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.status' }, op: 'eq', right: { kind: 'literal', value: 'paid' } },
212
+ ],
213
+ }
214
+ expect(evaluateWhen(node, ctx)).toBe(false)
215
+ })
216
+
217
+ it('nested AND inside OR', () => {
218
+ const node: WhenNode = {
219
+ kind: 'group',
220
+ op: 'AND',
221
+ children: [
222
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 100 } },
223
+ {
224
+ kind: 'group',
225
+ op: 'OR',
226
+ children: [
227
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.tag' }, op: 'eq', right: { kind: 'literal', value: 'gold' } },
228
+ { kind: 'predicate', left: { kind: 'ref', key: 'this.total' }, op: 'gt', right: { kind: 'literal', value: 5000 } },
229
+ ],
230
+ },
231
+ ],
232
+ }
233
+ expect(evaluateWhen(node, ctx)).toBe(true)
234
+ })
235
+ })
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // null handling
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe('evaluateWhen — null operands', () => {
242
+ const emptyCtx = makeContext({})
243
+
244
+ it('null == null → eq true', () => {
245
+ const node: WhenNode = {
246
+ kind: 'predicate',
247
+ left: { kind: 'ref', key: 'this.missing' },
248
+ op: 'eq',
249
+ right: { kind: 'literal', value: null },
250
+ }
251
+ expect(evaluateWhen(node, emptyCtx)).toBe(true)
252
+ })
253
+
254
+ it('null isempty → true', () => {
255
+ const node: WhenNode = {
256
+ kind: 'predicate',
257
+ left: { kind: 'ref', key: 'this.missing' },
258
+ op: 'isempty',
259
+ }
260
+ expect(evaluateWhen(node, emptyCtx)).toBe(true)
261
+ })
262
+
263
+ it('null gt 0 → false (no exception)', () => {
264
+ const node: WhenNode = {
265
+ kind: 'predicate',
266
+ left: { kind: 'ref', key: 'this.missing' },
267
+ op: 'gt',
268
+ right: { kind: 'literal', value: 0 },
269
+ }
270
+ expect(() => evaluateWhen(node, emptyCtx)).not.toThrow()
271
+ expect(evaluateWhen(node, emptyCtx)).toBe(false)
272
+ })
273
+ })
274
+