@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,217 +1,267 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import type { Seed } from '@beechcms/core'
3
- import { D1WidgetRepository } from './d1-widget.repository'
4
-
5
- function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
6
- const allMock = vi.fn().mockResolvedValue({ results: allResults })
7
- const firstMock = vi.fn().mockResolvedValue(firstResult)
8
- const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
9
- const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
10
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
11
- }
12
-
13
- const seed: Seed = {
14
- slug: 'posts',
15
- label: 'Post',
16
- displayNameAlias: 'title',
17
- branches: [
18
- { id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
19
- { id: 'br_02', alias: 'price', type: 'number', label: 'Price' },
20
- ],
21
- }
22
-
23
- describe('D1WidgetRepository', () => {
24
- describe('aggregate', () => {
25
- it('returns 0 when no rows match', async () => {
26
- const { db } = makeMockDb([], null)
27
- const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'all')
28
- expect(value).toBe(0)
29
- })
30
-
31
- it('builds COUNT(*) for op=count', async () => {
32
- const { db, prepareMock } = makeMockDb([], { computed_value: 7 })
33
- const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'week')
34
- expect(value).toBe(7)
35
- const sql = prepareMock.mock.calls[0]![0] as string
36
- expect(sql).toMatch(/COUNT\(\*\)/)
37
- expect(sql).toMatch(/FROM content_posts/)
38
- expect(sql).toMatch(/created_at > unixepoch\('now', '-7 days'\)/)
39
- })
40
-
41
- it('throws UNSAFE_COLUMN when column alias is unknown', async () => {
42
- const { db } = makeMockDb()
43
- await expect(
44
- new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'unknown' }, 'all'),
45
- ).rejects.toThrow('UNSAFE_COLUMN')
46
- })
47
-
48
- it('builds SUM(CAST(... AS REAL)) for valid column', async () => {
49
- const { db, prepareMock } = makeMockDb([], { computed_value: 42 })
50
- await new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'price' }, 'all')
51
- expect(prepareMock.mock.calls[0]![0] as string).toMatch(/SUM\(CAST\(price AS REAL\)\)/)
52
- })
53
- })
54
-
55
- describe('growth', () => {
56
- it('runs two queries with current and previous filters', async () => {
57
- const allMock = vi.fn().mockResolvedValue({ results: [] })
58
- const firstMock = vi
59
- .fn()
60
- .mockResolvedValueOnce({ computed_value: 10 })
61
- .mockResolvedValueOnce({ computed_value: 5 })
62
- const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
63
- const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
64
- const db = { prepare: prepareMock } as any
65
-
66
- const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'month')
67
- expect(result).toEqual({ currentValue: 10, previousValue: 5 })
68
- expect(prepareMock).toHaveBeenCalledTimes(2)
69
- const sql0 = prepareMock.mock.calls[0]![0] as string
70
- const sql1 = prepareMock.mock.calls[1]![0] as string
71
- expect(sql0).toMatch(/-1 month/)
72
- expect(sql1).toMatch(/-2 months/)
73
- })
74
-
75
- it('returns zeros when both queries are empty', async () => {
76
- const { db } = makeMockDb([], null)
77
- const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'all')
78
- expect(result).toEqual({ currentValue: 0, previousValue: 0 })
79
- })
80
- })
81
-
82
- describe('leaderboard', () => {
83
- it('uses ORDER BY DESC by default and binds only the limit', async () => {
84
- const { db, prepareMock, bindMock } = makeMockDb([
85
- { id: 'a', label: 'A', score: 9 },
86
- ])
87
- const entries = await new D1WidgetRepository(db).leaderboard(seed, {
88
- scoreColumn: 'price',
89
- limit: 5,
90
- orderDirection: 'DESC',
91
- })
92
- expect(entries).toEqual([{ id: 'a', label: 'A', score: 9 }])
93
- const sql = prepareMock.mock.calls[0]![0] as string
94
- expect(sql).toMatch(/ORDER BY CAST\(price AS REAL\) DESC/)
95
- expect(bindMock).toHaveBeenCalledWith(5)
96
- })
97
-
98
- it('uses ORDER BY ASC when requested', async () => {
99
- const { db, prepareMock } = makeMockDb([])
100
- await new D1WidgetRepository(db).leaderboard(seed, {
101
- scoreColumn: 'price',
102
- limit: 3,
103
- orderDirection: 'ASC',
104
- })
105
- expect(prepareMock.mock.calls[0]![0] as string).toMatch(/ORDER BY CAST\(price AS REAL\) ASC/)
106
- })
107
-
108
- it('falls back to id when label is null', async () => {
109
- const { db } = makeMockDb([{ id: 'x', label: null, score: 1 }])
110
- const [entry] = await new D1WidgetRepository(db).leaderboard(seed, {
111
- scoreColumn: 'price',
112
- limit: 1,
113
- orderDirection: 'DESC',
114
- })
115
- expect(entry).toEqual({ id: 'x', label: 'x', score: 1 })
116
- })
117
- })
118
-
119
- describe('list', () => {
120
- it('returns raw rows and totalCount', async () => {
121
- const allMock = vi.fn().mockResolvedValue({ results: [{ id: '1', slug: 's', status: 'published', created_at: 1, updated_at: 2, title: 'T', price: 10 }] })
122
- const firstMock = vi.fn().mockResolvedValue({ total: 7 })
123
- const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
124
- const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
125
- const db = { prepare: prepareMock } as any
126
-
127
- const result = await new D1WidgetRepository(db).list(seed, { limit: 10, offset: 0 })
128
- expect(result.totalCount).toBe(7)
129
- expect(result.entries[0]?.title).toBe('T')
130
- })
131
-
132
- it('appends search LIKE filter against displayNameAlias column', async () => {
133
- const { db, prepareMock, bindMock } = makeMockDb([], { total: 0 })
134
- await new D1WidgetRepository(db).list(seed, { limit: 5, offset: 0, search: 'hello' })
135
- const sql = (prepareMock.mock.calls[1]?.[0] ?? prepareMock.mock.calls[0]![0]) as string
136
- expect(sql).toMatch(/title LIKE \?/)
137
- expect(bindMock).toHaveBeenCalledWith('%hello%', 5, 0)
138
- })
139
-
140
- it('skips filters with unknown operator', async () => {
141
- const { db, prepareMock } = makeMockDb([], { total: 0 })
142
- await new D1WidgetRepository(db).list(seed, {
143
- limit: 5,
144
- offset: 0,
145
- filters: [{ column: 'price', op: 'haxx', value: 1 }],
146
- })
147
- const dataSql = prepareMock.mock.calls[1]![0] as string
148
- expect(dataSql).not.toMatch(/haxx/)
149
- })
150
-
151
- it('skips filters with unsafe column', async () => {
152
- const { db, prepareMock } = makeMockDb([], { total: 0 })
153
- await new D1WidgetRepository(db).list(seed, {
154
- limit: 5,
155
- offset: 0,
156
- filters: [{ column: 'evil', op: 'eq', value: 1 }],
157
- })
158
- const dataSql = prepareMock.mock.calls[1]![0] as string
159
- expect(dataSql).not.toMatch(/evil/)
160
- })
161
-
162
- it('uses ORDER BY DESC branch when requested', async () => {
163
- const { db, prepareMock } = makeMockDb([], { total: 0 })
164
- await new D1WidgetRepository(db).list(seed, {
165
- limit: 5,
166
- offset: 0,
167
- orderByColumn: 'price',
168
- orderDirection: 'DESC',
169
- })
170
- const dataSql = prepareMock.mock.calls[1]![0] as string
171
- expect(dataSql).toMatch(/ORDER BY price DESC/)
172
- })
173
-
174
- it('falls back to created_at when orderByColumn is unsafe', async () => {
175
- const { db, prepareMock } = makeMockDb([], { total: 0 })
176
- await new D1WidgetRepository(db).list(seed, {
177
- limit: 5,
178
- offset: 0,
179
- orderByColumn: 'evil',
180
- })
181
- const dataSql = prepareMock.mock.calls[1]![0] as string
182
- expect(dataSql).toMatch(/ORDER BY created_at/)
183
- })
184
- })
185
-
186
- describe('timeseries', () => {
187
- it('groups by date bucket on created_at', async () => {
188
- const { db, prepareMock } = makeMockDb([
189
- { bucket_label: '2026-01-01', bucket_value: 3 },
190
- ])
191
- const points = await new D1WidgetRepository(db).timeseries(
192
- seed,
193
- { op: 'count' },
194
- 'all',
195
- 'created_at',
196
- )
197
- expect(points).toEqual([{ label: '2026-01-01', value: 3 }])
198
- const sql = prepareMock.mock.calls[0]![0] as string
199
- expect(sql).toMatch(/strftime\('%Y-%m-%d', created_at, 'unixepoch'\)/)
200
- expect(sql).toMatch(/GROUP BY bucket_label/)
201
- })
202
-
203
- it('CASTs non-system group columns as INTEGER', async () => {
204
- const { db, prepareMock } = makeMockDb([])
205
- await new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'price')
206
- const sql = prepareMock.mock.calls[0]![0] as string
207
- expect(sql).toMatch(/CAST\(price AS INTEGER\)/)
208
- })
209
-
210
- it('throws UNSAFE_COLUMN for invalid groupColumn', async () => {
211
- const { db } = makeMockDb()
212
- await expect(
213
- new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'evil'),
214
- ).rejects.toThrow('UNSAFE_COLUMN')
215
- })
216
- })
217
- })
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 } from 'vitest'
6
+ import type { Seed } from '@beechcms/core'
7
+ import { D1WidgetRepository } from './d1-widget.repository'
8
+
9
+ function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
10
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
11
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
12
+ const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
13
+ const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
14
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
15
+ }
16
+
17
+ const seed: Seed = {
18
+ slug: 'posts',
19
+ label: 'Post',
20
+ displayNameAlias: 'title',
21
+ branches: [
22
+ { id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
23
+ { id: 'br_02', alias: 'price', type: 'number', label: 'Price' },
24
+ ],
25
+ }
26
+
27
+ describe('D1WidgetRepository', () => {
28
+ describe('aggregate', () => {
29
+ it('returns 0 when no rows match', async () => {
30
+ const { db } = makeMockDb([], null)
31
+ const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'all')
32
+ expect(value).toBe(0)
33
+ })
34
+
35
+ it('builds COUNT(*) for op=count', async () => {
36
+ const { db, prepareMock } = makeMockDb([], { computed_value: 7 })
37
+ const value = await new D1WidgetRepository(db).aggregate(seed, { op: 'count' }, 'week')
38
+ expect(value).toBe(7)
39
+ const sql = prepareMock.mock.calls[0]![0] as string
40
+ expect(sql).toMatch(/COUNT\(\*\)/)
41
+ expect(sql).toMatch(/FROM content_posts/)
42
+ expect(sql).toMatch(/created_at > unixepoch\('now', '-7 days'\)/)
43
+ })
44
+
45
+ it('throws UNSAFE_COLUMN when column alias is unknown', async () => {
46
+ const { db } = makeMockDb()
47
+ await expect(
48
+ new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'unknown' }, 'all'),
49
+ ).rejects.toThrow('UNSAFE_COLUMN')
50
+ })
51
+
52
+ it('builds SUM(CAST(... AS REAL)) for valid column', async () => {
53
+ const { db, prepareMock } = makeMockDb([], { computed_value: 42 })
54
+ await new D1WidgetRepository(db).aggregate(seed, { op: 'sum', column: 'price' }, 'all')
55
+ expect(prepareMock.mock.calls[0]![0] as string).toMatch(/SUM\(CAST\(price AS REAL\)\)/)
56
+ })
57
+ })
58
+
59
+ describe('growth', () => {
60
+ it('runs two queries with current and previous filters', async () => {
61
+ const allMock = vi.fn().mockResolvedValue({ results: [] })
62
+ const firstMock = vi
63
+ .fn()
64
+ .mockResolvedValueOnce({ computed_value: 10 })
65
+ .mockResolvedValueOnce({ computed_value: 5 })
66
+ const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
67
+ const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
68
+ const db = { prepare: prepareMock } as any
69
+
70
+ const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'month')
71
+ expect(result).toEqual({ currentValue: 10, previousValue: 5 })
72
+ expect(prepareMock).toHaveBeenCalledTimes(2)
73
+ const sql0 = prepareMock.mock.calls[0]![0] as string
74
+ const sql1 = prepareMock.mock.calls[1]![0] as string
75
+ expect(sql0).toMatch(/-1 month/)
76
+ expect(sql1).toMatch(/-2 months/)
77
+ })
78
+
79
+ it('returns zeros when both queries are empty', async () => {
80
+ const { db } = makeMockDb([], null)
81
+ const result = await new D1WidgetRepository(db).growth(seed, { op: 'count' }, 'all')
82
+ expect(result).toEqual({ currentValue: 0, previousValue: 0 })
83
+ })
84
+ })
85
+
86
+ describe('leaderboard', () => {
87
+ it('uses ORDER BY DESC by default and binds only the limit', async () => {
88
+ const { db, prepareMock, bindMock } = makeMockDb([
89
+ { id: 'a', label: 'A', score: 9 },
90
+ ])
91
+ const entries = await new D1WidgetRepository(db).leaderboard(seed, {
92
+ scoreColumn: 'price',
93
+ limit: 5,
94
+ orderDirection: 'DESC',
95
+ })
96
+ expect(entries).toEqual([{ id: 'a', label: 'A', score: 9 }])
97
+ const sql = prepareMock.mock.calls[0]![0] as string
98
+ expect(sql).toMatch(/ORDER BY CAST\(price AS REAL\) DESC/)
99
+ expect(bindMock).toHaveBeenCalledWith(5)
100
+ })
101
+
102
+ it('uses ORDER BY ASC when requested', async () => {
103
+ const { db, prepareMock } = makeMockDb([])
104
+ await new D1WidgetRepository(db).leaderboard(seed, {
105
+ scoreColumn: 'price',
106
+ limit: 3,
107
+ orderDirection: 'ASC',
108
+ })
109
+ expect(prepareMock.mock.calls[0]![0] as string).toMatch(/ORDER BY CAST\(price AS REAL\) ASC/)
110
+ })
111
+
112
+ it('falls back to id when label is null', async () => {
113
+ const { db } = makeMockDb([{ id: 'x', label: null, score: 1 }])
114
+ const [entry] = await new D1WidgetRepository(db).leaderboard(seed, {
115
+ scoreColumn: 'price',
116
+ limit: 1,
117
+ orderDirection: 'DESC',
118
+ })
119
+ expect(entry).toEqual({ id: 'x', label: 'x', score: 1 })
120
+ })
121
+ })
122
+
123
+ describe('list', () => {
124
+ it('returns raw rows and totalCount', async () => {
125
+ const allMock = vi.fn().mockResolvedValue({ results: [{ id: '1', slug: 's', status: 'published', created_at: 1, updated_at: 2, title: 'T', price: 10 }] })
126
+ const firstMock = vi.fn().mockResolvedValue({ total: 7 })
127
+ const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
128
+ const prepareMock = vi.fn(() => ({ all: allMock, first: firstMock, bind: bindMock }))
129
+ const db = { prepare: prepareMock } as any
130
+
131
+ const result = await new D1WidgetRepository(db).list(seed, { limit: 10, offset: 0 })
132
+ expect(result.totalCount).toBe(7)
133
+ expect(result.entries[0]?.title).toBe('T')
134
+ })
135
+
136
+ it('appends search LIKE filter against displayNameAlias column', async () => {
137
+ const { db, prepareMock, bindMock } = makeMockDb([], { total: 0 })
138
+ await new D1WidgetRepository(db).list(seed, { limit: 5, offset: 0, search: 'hello' })
139
+ const sql = (prepareMock.mock.calls[1]?.[0] ?? prepareMock.mock.calls[0]![0]) as string
140
+ expect(sql).toMatch(/title LIKE \?/)
141
+ expect(bindMock).toHaveBeenCalledWith('%hello%', 5, 0)
142
+ })
143
+
144
+ it('skips filters with unknown operator', async () => {
145
+ const { db, prepareMock } = makeMockDb([], { total: 0 })
146
+ await new D1WidgetRepository(db).list(seed, {
147
+ limit: 5,
148
+ offset: 0,
149
+ filters: [{ column: 'price', op: 'haxx', value: 1 }],
150
+ })
151
+ const dataSql = prepareMock.mock.calls[1]![0] as string
152
+ expect(dataSql).not.toMatch(/haxx/)
153
+ })
154
+
155
+ it('skips filters with unsafe column', async () => {
156
+ const { db, prepareMock } = makeMockDb([], { total: 0 })
157
+ await new D1WidgetRepository(db).list(seed, {
158
+ limit: 5,
159
+ offset: 0,
160
+ filters: [{ column: 'evil', op: 'eq', value: 1 }],
161
+ })
162
+ const dataSql = prepareMock.mock.calls[1]![0] as string
163
+ expect(dataSql).not.toMatch(/evil/)
164
+ })
165
+
166
+ it('uses ORDER BY DESC branch when requested', async () => {
167
+ const { db, prepareMock } = makeMockDb([], { total: 0 })
168
+ await new D1WidgetRepository(db).list(seed, {
169
+ limit: 5,
170
+ offset: 0,
171
+ orderByColumn: 'price',
172
+ orderDirection: 'DESC',
173
+ })
174
+ const dataSql = prepareMock.mock.calls[1]![0] as string
175
+ expect(dataSql).toMatch(/ORDER BY price DESC/)
176
+ })
177
+
178
+ it('falls back to created_at when orderByColumn is unsafe', async () => {
179
+ const { db, prepareMock } = makeMockDb([], { total: 0 })
180
+ await new D1WidgetRepository(db).list(seed, {
181
+ limit: 5,
182
+ offset: 0,
183
+ orderByColumn: 'evil',
184
+ })
185
+ const dataSql = prepareMock.mock.calls[1]![0] as string
186
+ expect(dataSql).toMatch(/ORDER BY created_at/)
187
+ })
188
+ })
189
+
190
+ describe('timeseries', () => {
191
+ it('groups by date bucket on created_at', async () => {
192
+ const { db, prepareMock } = makeMockDb([
193
+ { bucket_label: '2026-01-01', bucket_value: 3 },
194
+ ])
195
+ const points = await new D1WidgetRepository(db).timeseries(
196
+ seed,
197
+ { op: 'count' },
198
+ 'all',
199
+ 'created_at',
200
+ )
201
+ expect(points).toEqual([{ label: '2026-01-01', value: 3 }])
202
+ const sql = prepareMock.mock.calls[0]![0] as string
203
+ expect(sql).toMatch(/strftime\('%Y-%m-%d', created_at, 'unixepoch'\)/)
204
+ expect(sql).toMatch(/GROUP BY bucket_label/)
205
+ })
206
+
207
+ it('CASTs non-system group columns as INTEGER', async () => {
208
+ const { db, prepareMock } = makeMockDb([])
209
+ await new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'price')
210
+ const sql = prepareMock.mock.calls[0]![0] as string
211
+ expect(sql).toMatch(/CAST\(price AS INTEGER\)/)
212
+ })
213
+
214
+ it('throws UNSAFE_COLUMN for invalid groupColumn', async () => {
215
+ const { db } = makeMockDb()
216
+ await expect(
217
+ new D1WidgetRepository(db).timeseries(seed, { op: 'count' }, 'all', 'evil'),
218
+ ).rejects.toThrow('UNSAFE_COLUMN')
219
+ })
220
+ })
221
+
222
+ describe('distribution', () => {
223
+ it('groups by column value, descending by count', async () => {
224
+ const { db, prepareMock, bindMock } = makeMockDb([
225
+ { label: 'published', value: 5 },
226
+ { label: 'draft', value: 2 },
227
+ ])
228
+ const slices = await new D1WidgetRepository(db).distribution(seed, 'title', 'all', 8)
229
+ expect(slices).toEqual([
230
+ { label: 'published', value: 5 },
231
+ { label: 'draft', value: 2 },
232
+ ])
233
+ const sql = prepareMock.mock.calls[0]![0] as string
234
+ expect(sql).toMatch(/SELECT title as label, COUNT\(\*\) as value/)
235
+ expect(sql).toMatch(/FROM content_posts/)
236
+ expect(sql).toMatch(/GROUP BY title/)
237
+ expect(sql).toMatch(/ORDER BY value DESC/)
238
+ expect(bindMock).toHaveBeenCalledWith(8)
239
+ })
240
+
241
+ it('labels NULL values as ∅', async () => {
242
+ const { db } = makeMockDb([{ label: null, value: 3 }])
243
+ const slices = await new D1WidgetRepository(db).distribution(seed, 'title', 'all', 8)
244
+ expect(slices).toEqual([{ label: '∅', value: 3 }])
245
+ })
246
+
247
+ it('returns [] on empty results', async () => {
248
+ const { db } = makeMockDb([])
249
+ const slices = await new D1WidgetRepository(db).distribution(seed, 'title', 'all', 8)
250
+ expect(slices).toEqual([])
251
+ })
252
+
253
+ it('applies the time window bracket', async () => {
254
+ const { db, prepareMock } = makeMockDb([])
255
+ await new D1WidgetRepository(db).distribution(seed, 'title', 'week', 8)
256
+ const sql = prepareMock.mock.calls[0]![0] as string
257
+ expect(sql).toMatch(/created_at > unixepoch\('now', '-7 days'\)/)
258
+ })
259
+
260
+ it('throws UNSAFE_COLUMN for invalid column', async () => {
261
+ const { db } = makeMockDb()
262
+ await expect(
263
+ new D1WidgetRepository(db).distribution(seed, 'evil', 'all', 8),
264
+ ).rejects.toThrow('UNSAFE_COLUMN')
265
+ })
266
+ })
267
+ })