@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,98 +1,102 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { ISessionRepository, NewRefreshToken, RefreshTokenRecord, ActiveSessionSummary, IClock } from '@beechcms/core'
3
-
4
- type RefreshTokenRow = {
5
- id: string
6
- user_id: string
7
- token_hash: string
8
- expires_at: number
9
- created_at: number
10
- revoked_at: number | null
11
- }
12
-
13
- type SessionSummaryRow = {
14
- id: string
15
- created_at: number
16
- expires_at: number
17
- }
18
-
19
- function rowToRecord(row: RefreshTokenRow): RefreshTokenRecord {
20
- return {
21
- id: row.id,
22
- userId: row.user_id,
23
- tokenHash: row.token_hash,
24
- expiresAt: row.expires_at,
25
- createdAt: row.created_at,
26
- revokedAt: row.revoked_at,
27
- }
28
- }
29
-
30
- export class D1SessionRepository implements ISessionRepository {
31
- constructor(
32
- private readonly db: D1Database,
33
- private readonly clock: IClock,
34
- ) {}
35
-
36
- async saveRefreshToken(record: NewRefreshToken): Promise<void> {
37
- await this.db
38
- .prepare('INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)')
39
- .bind(record.id, record.userId, record.tokenHash, record.expiresAt, this.clock.nowSeconds())
40
- .run()
41
- }
42
-
43
- async findActiveByHash(tokenHash: string, nowTimestamp: number): Promise<RefreshTokenRecord | null> {
44
- const row = await this.db
45
- .prepare(
46
- `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
47
- FROM refresh_tokens
48
- WHERE token_hash = ? AND expires_at > ? AND revoked_at IS NULL
49
- LIMIT 1`
50
- )
51
- .bind(tokenHash, nowTimestamp)
52
- .first<RefreshTokenRow>()
53
- return row ? rowToRecord(row) : null
54
- }
55
-
56
- async revokeByHash(tokenHash: string, nowTimestamp: number): Promise<boolean> {
57
- const result = await this.db
58
- .prepare(
59
- `UPDATE refresh_tokens
60
- SET revoked_at = ?
61
- WHERE token_hash = ? AND revoked_at IS NULL AND expires_at >= ?`
62
- )
63
- .bind(nowTimestamp, tokenHash, nowTimestamp)
64
- .run()
65
- const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
66
- return changes > 0
67
- }
68
-
69
- async revokeAllForUser(userId: string, nowTimestamp: number): Promise<void> {
70
- await this.db
71
- .prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL')
72
- .bind(nowTimestamp, userId)
73
- .run()
74
- }
75
-
76
- async listActiveForUser(userId: string, nowTimestamp: number, limit: number): Promise<ActiveSessionSummary[]> {
77
- const { results } = await this.db
78
- .prepare(
79
- `SELECT id, created_at, expires_at FROM refresh_tokens
80
- WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
81
- ORDER BY created_at DESC LIMIT ?`
82
- )
83
- .bind(userId, nowTimestamp, limit)
84
- .all<SessionSummaryRow>()
85
- return (results ?? []).map(row => ({ id: row.id, createdAt: row.created_at, expiresAt: row.expires_at }))
86
- }
87
-
88
- async revokeById(sessionId: string, userId: string, nowTimestamp: number): Promise<boolean> {
89
- const result = await this.db
90
- .prepare(
91
- 'UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL'
92
- )
93
- .bind(nowTimestamp, sessionId, userId)
94
- .run()
95
- const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
96
- return changes > 0
97
- }
98
- }
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
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type { ISessionRepository, NewRefreshToken, RefreshTokenRecord, ActiveSessionSummary, IClock } from '@beechcms/core'
7
+
8
+ type RefreshTokenRow = {
9
+ id: string
10
+ user_id: string
11
+ token_hash: string
12
+ expires_at: number
13
+ created_at: number
14
+ revoked_at: number | null
15
+ }
16
+
17
+ type SessionSummaryRow = {
18
+ id: string
19
+ created_at: number
20
+ expires_at: number
21
+ }
22
+
23
+ function rowToRecord(row: RefreshTokenRow): RefreshTokenRecord {
24
+ return {
25
+ id: row.id,
26
+ userId: row.user_id,
27
+ tokenHash: row.token_hash,
28
+ expiresAt: row.expires_at,
29
+ createdAt: row.created_at,
30
+ revokedAt: row.revoked_at,
31
+ }
32
+ }
33
+
34
+ export class D1SessionRepository implements ISessionRepository {
35
+ constructor(
36
+ private readonly db: D1Database,
37
+ private readonly clock: IClock,
38
+ ) {}
39
+
40
+ async saveRefreshToken(record: NewRefreshToken): Promise<void> {
41
+ await this.db
42
+ .prepare('INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)')
43
+ .bind(record.id, record.userId, record.tokenHash, record.expiresAt, this.clock.nowSeconds())
44
+ .run()
45
+ }
46
+
47
+ async findActiveByHash(tokenHash: string, nowTimestamp: number): Promise<RefreshTokenRecord | null> {
48
+ const row = await this.db
49
+ .prepare(
50
+ `SELECT id, user_id, token_hash, expires_at, created_at, revoked_at
51
+ FROM refresh_tokens
52
+ WHERE token_hash = ? AND expires_at > ? AND revoked_at IS NULL
53
+ LIMIT 1`
54
+ )
55
+ .bind(tokenHash, nowTimestamp)
56
+ .first<RefreshTokenRow>()
57
+ return row ? rowToRecord(row) : null
58
+ }
59
+
60
+ async revokeByHash(tokenHash: string, nowTimestamp: number): Promise<boolean> {
61
+ const result = await this.db
62
+ .prepare(
63
+ `UPDATE refresh_tokens
64
+ SET revoked_at = ?
65
+ WHERE token_hash = ? AND revoked_at IS NULL AND expires_at >= ?`
66
+ )
67
+ .bind(nowTimestamp, tokenHash, nowTimestamp)
68
+ .run()
69
+ const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
70
+ return changes > 0
71
+ }
72
+
73
+ async revokeAllForUser(userId: string, nowTimestamp: number): Promise<void> {
74
+ await this.db
75
+ .prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL')
76
+ .bind(nowTimestamp, userId)
77
+ .run()
78
+ }
79
+
80
+ async listActiveForUser(userId: string, nowTimestamp: number, limit: number): Promise<ActiveSessionSummary[]> {
81
+ const { results } = await this.db
82
+ .prepare(
83
+ `SELECT id, created_at, expires_at FROM refresh_tokens
84
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
85
+ ORDER BY created_at DESC LIMIT ?`
86
+ )
87
+ .bind(userId, nowTimestamp, limit)
88
+ .all<SessionSummaryRow>()
89
+ return (results ?? []).map(row => ({ id: row.id, createdAt: row.created_at, expiresAt: row.expires_at }))
90
+ }
91
+
92
+ async revokeById(sessionId: string, userId: string, nowTimestamp: number): Promise<boolean> {
93
+ const result = await this.db
94
+ .prepare(
95
+ 'UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL'
96
+ )
97
+ .bind(nowTimestamp, sessionId, userId)
98
+ .run()
99
+ const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
100
+ return changes > 0
101
+ }
102
+ }
@@ -0,0 +1,36 @@
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
+ /// <reference types="@cloudflare/workers-types" />
6
+
7
+ export interface ISetupChecklistRepository {
8
+ getExistingTableNames(): Promise<Set<string>>
9
+ countAdmins(): Promise<number>
10
+ countEntriesInSeed(slug: string): Promise<number>
11
+ }
12
+
13
+ export class D1SetupChecklistRepository implements ISetupChecklistRepository {
14
+ constructor(private readonly db: D1Database) {}
15
+
16
+ async getExistingTableNames(): Promise<Set<string>> {
17
+ const result = await this.db
18
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table'`)
19
+ .all<{ name: string }>()
20
+ return new Set((result.results ?? []).map(row => row.name))
21
+ }
22
+
23
+ async countAdmins(): Promise<number> {
24
+ const result = await this.db
25
+ .prepare(`SELECT COUNT(*) as count FROM users WHERE role = 'admin'`)
26
+ .first<{ count: number }>()
27
+ return result?.count ?? 0
28
+ }
29
+
30
+ async countEntriesInSeed(slug: string): Promise<number> {
31
+ const result = await this.db
32
+ .prepare(`SELECT COUNT(*) as count FROM content_${slug}`)
33
+ .first<{ count: number }>()
34
+ return result?.count ?? 0
35
+ }
36
+ }
@@ -1,147 +1,151 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { D1UserRepository } from './d1-user.repository'
3
-
4
- const USER_ROW = {
5
- id: 'u1',
6
- email: 'test@test.com',
7
- name: 'Test User',
8
- password_hash: 'hash-abc',
9
- role: 'admin',
10
- avatar_url: null,
11
- notification_prefs: '{}',
12
- }
13
-
14
- function makeMockDb(opts: {
15
- firstResult?: unknown
16
- runChanges?: number
17
- allResults?: unknown[]
18
- } = {}) {
19
- const { firstResult = null, runChanges = 1, allResults = [] } = opts
20
- const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
21
- const firstMock = vi.fn().mockResolvedValue(firstResult)
22
- const allMock = vi.fn().mockResolvedValue({ results: allResults })
23
- const bindMock = vi.fn(() => ({ first: firstMock, all: allMock, run: runMock }))
24
- // prepare() may be called without bind() for countAll (which calls .first() directly)
25
- const prepareMock = vi.fn(() => ({ bind: bindMock, first: firstMock, run: runMock }))
26
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock }
27
- }
28
-
29
- describe('D1UserRepository', () => {
30
- describe('countAll', () => {
31
- it('returns the count value from the database', async () => {
32
- const { db, firstMock } = makeMockDb()
33
- firstMock.mockResolvedValue({ count: 5 })
34
- expect(await new D1UserRepository(db).countAll()).toBe(5)
35
- })
36
-
37
- it('returns 0 when the query returns null', async () => {
38
- const { db } = makeMockDb({ firstResult: null })
39
- expect(await new D1UserRepository(db).countAll()).toBe(0)
40
- })
41
- })
42
-
43
- describe('findByEmail', () => {
44
- it('returns a UserRecord with camelCase keys mapped from the snake_case row', async () => {
45
- const { db } = makeMockDb({ firstResult: USER_ROW })
46
- const result = await new D1UserRepository(db).findByEmail('test@test.com')
47
- expect(result).toEqual({
48
- id: 'u1',
49
- email: 'test@test.com',
50
- name: 'Test User',
51
- passwordHash: 'hash-abc',
52
- role: 'admin',
53
- avatarUrl: null,
54
- notificationPreferences: '{}',
55
- })
56
- })
57
-
58
- it('returns null when no user is found', async () => {
59
- const { db } = makeMockDb({ firstResult: null })
60
- expect(await new D1UserRepository(db).findByEmail('nobody@test.com')).toBeNull()
61
- })
62
- })
63
-
64
- describe('findById', () => {
65
- it('returns a mapped UserRecord when found', async () => {
66
- const { db } = makeMockDb({ firstResult: USER_ROW })
67
- const result = await new D1UserRepository(db).findById('u1')
68
- expect(result?.id).toBe('u1')
69
- expect(result?.passwordHash).toBe('hash-abc')
70
- })
71
-
72
- it('returns null when the user is not found', async () => {
73
- const { db } = makeMockDb({ firstResult: null })
74
- expect(await new D1UserRepository(db).findById('unknown')).toBeNull()
75
- })
76
- })
77
-
78
- describe('create', () => {
79
- it('calls prepare with an INSERT INTO users statement', async () => {
80
- const { db, prepareMock } = makeMockDb()
81
- await new D1UserRepository(db).create({
82
- id: 'u1', email: 'a@b.com', passwordHash: 'hash', role: 'admin', name: 'Test',
83
- })
84
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO users'))
85
- })
86
-
87
- it('binds id, email, passwordHash, role, name in the correct order', async () => {
88
- const { db, bindMock } = makeMockDb()
89
- await new D1UserRepository(db).create({
90
- id: 'u1', email: 'a@b.com', passwordHash: 'hash', role: 'admin', name: 'Test',
91
- })
92
- expect(bindMock).toHaveBeenCalledWith('u1', 'a@b.com', 'hash', 'admin', 'Test')
93
- })
94
- })
95
-
96
- describe('updateProfile', () => {
97
- it('does nothing when no fields are provided', async () => {
98
- const { db, prepareMock } = makeMockDb()
99
- await new D1UserRepository(db).updateProfile('u1', {})
100
- expect(prepareMock).not.toHaveBeenCalled()
101
- })
102
-
103
- it('generates a SET clause containing only the provided field', async () => {
104
- const { db, prepareMock } = makeMockDb()
105
- await new D1UserRepository(db).updateProfile('u1', { name: 'New Name' })
106
- const sql: string = prepareMock.mock.calls[0][0]
107
- expect(sql).toContain('name = ?')
108
- expect(sql).not.toContain('email = ?')
109
- })
110
-
111
- it('includes both fields when both name and email are provided', async () => {
112
- const { db, prepareMock } = makeMockDb()
113
- await new D1UserRepository(db).updateProfile('u1', { name: 'N', email: 'e@e.com' })
114
- const sql: string = prepareMock.mock.calls[0][0]
115
- expect(sql).toContain('name = ?')
116
- expect(sql).toContain('email = ?')
117
- })
118
- })
119
-
120
- describe('updatePasswordHash', () => {
121
- it('calls UPDATE users SET password_hash', async () => {
122
- const { db, prepareMock } = makeMockDb()
123
- await new D1UserRepository(db).updatePasswordHash('u1', 'new-hash')
124
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('password_hash'))
125
- })
126
- })
127
-
128
- describe('updateAvatarUrl', () => {
129
- it('calls UPDATE users SET avatar_url', async () => {
130
- const { db, prepareMock } = makeMockDb()
131
- await new D1UserRepository(db).updateAvatarUrl('u1', 'https://cdn.example.com/avatar.jpg')
132
- expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('avatar_url'))
133
- })
134
- })
135
-
136
- describe('emailBelongsToAnotherUser', () => {
137
- it('returns true when another user row is found with that email', async () => {
138
- const { db } = makeMockDb({ firstResult: { id: 'other-user' } })
139
- expect(await new D1UserRepository(db).emailBelongsToAnotherUser('taken@test.com', 'current')).toBe(true)
140
- })
141
-
142
- it('returns false when no other user owns the email', async () => {
143
- const { db } = makeMockDb({ firstResult: null })
144
- expect(await new D1UserRepository(db).emailBelongsToAnotherUser('free@test.com', 'current')).toBe(false)
145
- })
146
- })
147
- })
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 { D1UserRepository } from './d1-user.repository'
7
+
8
+ const USER_ROW = {
9
+ id: 'u1',
10
+ email: 'test@test.com',
11
+ name: 'Test User',
12
+ password_hash: 'hash-abc',
13
+ role: 'admin',
14
+ avatar_url: null,
15
+ notification_prefs: '{}',
16
+ }
17
+
18
+ function makeMockDb(opts: {
19
+ firstResult?: unknown
20
+ runChanges?: number
21
+ allResults?: unknown[]
22
+ } = {}) {
23
+ const { firstResult = null, runChanges = 1, allResults = [] } = opts
24
+ const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
25
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
26
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
27
+ const bindMock = vi.fn(() => ({ first: firstMock, all: allMock, run: runMock }))
28
+ // prepare() may be called without bind() for countAll (which calls .first() directly)
29
+ const prepareMock = vi.fn(() => ({ bind: bindMock, first: firstMock, run: runMock }))
30
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock }
31
+ }
32
+
33
+ describe('D1UserRepository', () => {
34
+ describe('countAll', () => {
35
+ it('returns the count value from the database', async () => {
36
+ const { db, firstMock } = makeMockDb()
37
+ firstMock.mockResolvedValue({ count: 5 })
38
+ expect(await new D1UserRepository(db).countAll()).toBe(5)
39
+ })
40
+
41
+ it('returns 0 when the query returns null', async () => {
42
+ const { db } = makeMockDb({ firstResult: null })
43
+ expect(await new D1UserRepository(db).countAll()).toBe(0)
44
+ })
45
+ })
46
+
47
+ describe('findByEmail', () => {
48
+ it('returns a UserRecord with camelCase keys mapped from the snake_case row', async () => {
49
+ const { db } = makeMockDb({ firstResult: USER_ROW })
50
+ const result = await new D1UserRepository(db).findByEmail('test@test.com')
51
+ expect(result).toEqual({
52
+ id: 'u1',
53
+ email: 'test@test.com',
54
+ name: 'Test User',
55
+ passwordHash: 'hash-abc',
56
+ role: 'admin',
57
+ avatarUrl: null,
58
+ notificationPreferences: '{}',
59
+ })
60
+ })
61
+
62
+ it('returns null when no user is found', async () => {
63
+ const { db } = makeMockDb({ firstResult: null })
64
+ expect(await new D1UserRepository(db).findByEmail('nobody@test.com')).toBeNull()
65
+ })
66
+ })
67
+
68
+ describe('findById', () => {
69
+ it('returns a mapped UserRecord when found', async () => {
70
+ const { db } = makeMockDb({ firstResult: USER_ROW })
71
+ const result = await new D1UserRepository(db).findById('u1')
72
+ expect(result?.id).toBe('u1')
73
+ expect(result?.passwordHash).toBe('hash-abc')
74
+ })
75
+
76
+ it('returns null when the user is not found', async () => {
77
+ const { db } = makeMockDb({ firstResult: null })
78
+ expect(await new D1UserRepository(db).findById('unknown')).toBeNull()
79
+ })
80
+ })
81
+
82
+ describe('create', () => {
83
+ it('calls prepare with an INSERT INTO users statement', async () => {
84
+ const { db, prepareMock } = makeMockDb()
85
+ await new D1UserRepository(db).create({
86
+ id: 'u1', email: 'a@b.com', passwordHash: 'hash', role: 'admin', name: 'Test', surname: null,
87
+ })
88
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO users'))
89
+ })
90
+
91
+ it('binds id, email, passwordHash, role, name, surname in the correct order', async () => {
92
+ const { db, bindMock } = makeMockDb()
93
+ await new D1UserRepository(db).create({
94
+ id: 'u1', email: 'a@b.com', passwordHash: 'hash', role: 'admin', name: 'Test', surname: null,
95
+ })
96
+ expect(bindMock).toHaveBeenCalledWith('u1', 'a@b.com', 'hash', 'admin', 'Test', null)
97
+ })
98
+ })
99
+
100
+ describe('updateProfile', () => {
101
+ it('does nothing when no fields are provided', async () => {
102
+ const { db, prepareMock } = makeMockDb()
103
+ await new D1UserRepository(db).updateProfile('u1', {})
104
+ expect(prepareMock).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('generates a SET clause containing only the provided field', async () => {
108
+ const { db, prepareMock } = makeMockDb()
109
+ await new D1UserRepository(db).updateProfile('u1', { name: 'New Name' })
110
+ const sql: string = prepareMock.mock.calls[0][0]
111
+ expect(sql).toContain('name = ?')
112
+ expect(sql).not.toContain('email = ?')
113
+ })
114
+
115
+ it('includes both fields when both name and email are provided', async () => {
116
+ const { db, prepareMock } = makeMockDb()
117
+ await new D1UserRepository(db).updateProfile('u1', { name: 'N', email: 'e@e.com' })
118
+ const sql: string = prepareMock.mock.calls[0][0]
119
+ expect(sql).toContain('name = ?')
120
+ expect(sql).toContain('email = ?')
121
+ })
122
+ })
123
+
124
+ describe('updatePasswordHash', () => {
125
+ it('calls UPDATE users SET password_hash', async () => {
126
+ const { db, prepareMock } = makeMockDb()
127
+ await new D1UserRepository(db).updatePasswordHash('u1', 'new-hash')
128
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('password_hash'))
129
+ })
130
+ })
131
+
132
+ describe('updateAvatarUrl', () => {
133
+ it('calls UPDATE users SET avatar_url', async () => {
134
+ const { db, prepareMock } = makeMockDb()
135
+ await new D1UserRepository(db).updateAvatarUrl('u1', 'https://cdn.example.com/avatar.jpg')
136
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('avatar_url'))
137
+ })
138
+ })
139
+
140
+ describe('emailBelongsToAnotherUser', () => {
141
+ it('returns true when another user row is found with that email', async () => {
142
+ const { db } = makeMockDb({ firstResult: { id: 'other-user' } })
143
+ expect(await new D1UserRepository(db).emailBelongsToAnotherUser('taken@test.com', 'current')).toBe(true)
144
+ })
145
+
146
+ it('returns false when no other user owns the email', async () => {
147
+ const { db } = makeMockDb({ firstResult: null })
148
+ expect(await new D1UserRepository(db).emailBelongsToAnotherUser('free@test.com', 'current')).toBe(false)
149
+ })
150
+ })
151
+ })