@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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
|
@@ -1,221 +1,267 @@
|
|
|
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
|
-
|
|
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
|
+
})
|