@setzkasten-cms/astro-admin 0.6.0
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/LICENSE +37 -0
- package/package.json +70 -0
- package/src/admin-page.astro +148 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
- package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
- package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
- package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
- package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
- package/src/api-routes/__tests__/section-management.test.ts +284 -0
- package/src/api-routes/_storage-config.ts +54 -0
- package/src/api-routes/asset-proxy.ts +76 -0
- package/src/api-routes/auth-callback.ts +105 -0
- package/src/api-routes/auth-login.ts +87 -0
- package/src/api-routes/auth-logout.ts +9 -0
- package/src/api-routes/auth-session.ts +36 -0
- package/src/api-routes/catalog-add.ts +151 -0
- package/src/api-routes/catalog-export.ts +86 -0
- package/src/api-routes/catalog-helpers.ts +83 -0
- package/src/api-routes/catalog-list.ts +12 -0
- package/src/api-routes/config.ts +30 -0
- package/src/api-routes/deploy-hook.ts +69 -0
- package/src/api-routes/github-proxy.ts +111 -0
- package/src/api-routes/init-add-section.ts +511 -0
- package/src/api-routes/init-apply.ts +270 -0
- package/src/api-routes/init-migrate.ts +262 -0
- package/src/api-routes/init-scan-page.ts +336 -0
- package/src/api-routes/init-scan.ts +162 -0
- package/src/api-routes/pages.ts +17 -0
- package/src/api-routes/section-add.ts +189 -0
- package/src/api-routes/section-commit-pending.ts +147 -0
- package/src/api-routes/section-delete.ts +141 -0
- package/src/api-routes/section-duplicate.ts +144 -0
- package/src/api-routes/section-management.ts +95 -0
- package/src/api-routes/section-prepare-copy.ts +93 -0
- package/src/api-routes/section-prepare.ts +121 -0
- package/src/env.d.ts +7 -0
- package/src/init/__tests__/page-level.test.ts +1033 -0
- package/src/init/__tests__/page-list-coverage.test.ts +474 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
- package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
- package/src/init/__tests__/section-pipeline.test.ts +393 -0
- package/src/init/analyzer-types.ts +92 -0
- package/src/init/astro-config-patcher.ts +98 -0
- package/src/init/astro-detector.ts +207 -0
- package/src/init/astro-section-analyzer-v2.ts +1663 -0
- package/src/init/field-label-enricher.ts +72 -0
- package/src/init/template-patcher-v2.ts +1957 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for catalog API helper logic (pure functions — no GitHub API).
|
|
3
|
+
*
|
|
4
|
+
* The catalog API routes themselves need an Astro + GitHub environment,
|
|
5
|
+
* so we test the pure helper functions that are extracted from the route logic.
|
|
6
|
+
*
|
|
7
|
+
* Covered:
|
|
8
|
+
* 1. buildCatalogResponse — shapes registry list for the API response
|
|
9
|
+
* 2. validateCatalogAddBody — validates POST /api/setzkasten/catalog/add request body
|
|
10
|
+
* 3. buildCatalogAddCommit — builds file paths for a catalog add commit
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
buildCatalogResponse,
|
|
16
|
+
validateCatalogAddBody,
|
|
17
|
+
buildCatalogAddCommit,
|
|
18
|
+
} from '../catalog-helpers'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// buildCatalogResponse
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
describe('buildCatalogResponse', () => {
|
|
25
|
+
it('returns name, label, description, icon for each template', () => {
|
|
26
|
+
const result = buildCatalogResponse()
|
|
27
|
+
expect(result.length).toBeGreaterThanOrEqual(3)
|
|
28
|
+
for (const item of result) {
|
|
29
|
+
expect(item.name).toBeTruthy()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('includes hero, features, cta', () => {
|
|
34
|
+
const names = buildCatalogResponse().map(t => t.name)
|
|
35
|
+
expect(names).toContain('hero')
|
|
36
|
+
expect(names).toContain('features')
|
|
37
|
+
expect(names).toContain('cta')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('includes schema in each entry', () => {
|
|
41
|
+
const result = buildCatalogResponse()
|
|
42
|
+
for (const item of result) {
|
|
43
|
+
expect(item.schema?.fields).toBeDefined()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('includes defaultContent in each entry', () => {
|
|
48
|
+
const result = buildCatalogResponse()
|
|
49
|
+
for (const item of result) {
|
|
50
|
+
expect(item.defaultContent).toBeDefined()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// validateCatalogAddBody
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe('validateCatalogAddBody', () => {
|
|
60
|
+
it('accepts valid body with templateName and pageKey', () => {
|
|
61
|
+
expect(() => validateCatalogAddBody({ templateName: 'hero', pageKey: 'index' })).not.toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('accepts body with sectionKey override', () => {
|
|
65
|
+
expect(() => validateCatalogAddBody({ templateName: 'hero', pageKey: 'index', sectionKey: 'hero--top' })).not.toThrow()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws when templateName is missing', () => {
|
|
69
|
+
expect(() => validateCatalogAddBody({ pageKey: 'index' })).toThrow(/templateName/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('throws when pageKey is missing', () => {
|
|
73
|
+
expect(() => validateCatalogAddBody({ templateName: 'hero' })).toThrow(/pageKey/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('throws when templateName is not in registry', () => {
|
|
77
|
+
expect(() => validateCatalogAddBody({ templateName: 'nonexistent-xyz', pageKey: 'index' })).toThrow()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// buildCatalogAddCommit
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('buildCatalogAddCommit', () => {
|
|
86
|
+
const opts = {
|
|
87
|
+
contentPath: 'content',
|
|
88
|
+
projectPrefix: '',
|
|
89
|
+
pageKey: 'index',
|
|
90
|
+
sectionKey: 'hero',
|
|
91
|
+
templateName: 'hero',
|
|
92
|
+
pageConfigPath: 'content/pages/_index.json',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('includes content JSON path', () => {
|
|
96
|
+
const result = buildCatalogAddCommit(opts)
|
|
97
|
+
expect(result.sectionJsonPath).toBe('content/_sections/hero.json')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('applies projectPrefix to paths', () => {
|
|
101
|
+
const result = buildCatalogAddCommit({ ...opts, projectPrefix: 'apps/website' })
|
|
102
|
+
expect(result.sectionJsonPath.startsWith('apps/website/')).toBe(true)
|
|
103
|
+
expect(result.pageConfigPath.startsWith('apps/website/')).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('uses sectionKey for the content JSON filename', () => {
|
|
107
|
+
const result = buildCatalogAddCommit({ ...opts, sectionKey: 'hero--2' })
|
|
108
|
+
expect(result.sectionJsonPath).toContain('hero--2.json')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('pageConfigPath matches the pageKey', () => {
|
|
112
|
+
const result = buildCatalogAddCommit({ ...opts, pageKey: 'about', pageConfigPath: 'content/pages/_about.json' })
|
|
113
|
+
expect(result.pageConfigPath).toBe('content/pages/_about.json')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the deferred-commit model: prepare-copy, pendingDeletes, and the
|
|
3
|
+
* extended commit-pending (empty sections array for delete-only commits).
|
|
4
|
+
*
|
|
5
|
+
* These tests catch the bugs that were found in v0.6.x:
|
|
6
|
+
* - Duplicated sections appearing empty in the editor
|
|
7
|
+
* - No content in preview after duplicate
|
|
8
|
+
* - Commits triggered on every duplicate/delete instead of batched
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { generateDuplicateKey, duplicateInPageConfig } from '../section-management'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// 1. Duplicate key generation (reused by prepare-copy)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
describe('generateDuplicateKey (used by prepare-copy)', () => {
|
|
19
|
+
it('appends --copy to simple key', () => {
|
|
20
|
+
expect(generateDuplicateKey(['hero', 'features'], 'hero')).toBe('hero--copy')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('appends --copy2 when --copy already exists', () => {
|
|
24
|
+
expect(generateDuplicateKey(['hero', 'hero--copy'], 'hero')).toBe('hero--copy2')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('increments suffix until unique', () => {
|
|
28
|
+
expect(generateDuplicateKey(['hero', 'hero--copy', 'hero--copy2', 'hero--copy3'], 'hero'))
|
|
29
|
+
.toBe('hero--copy4')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('generates copy of a copy', () => {
|
|
33
|
+
expect(generateDuplicateKey(['hero', 'hero--copy'], 'hero--copy')).toBe('hero--copy--copy')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('handles multi-instance keys (hero--about → hero--about--copy)', () => {
|
|
37
|
+
expect(generateDuplicateKey(['hero--about'], 'hero--about')).toBe('hero--about--copy')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// 2. duplicateInPageConfig — used by prepare-copy to build updatedPageConfig
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe('duplicateInPageConfig (used by prepare-copy)', () => {
|
|
46
|
+
const baseConfig = {
|
|
47
|
+
sections: [
|
|
48
|
+
{ key: 'hero', enabled: true, order: 0 },
|
|
49
|
+
{ key: 'features', enabled: true, order: 1 },
|
|
50
|
+
{ key: 'cta', enabled: true, order: 2 },
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('inserts copy immediately after original', () => {
|
|
55
|
+
const result = duplicateInPageConfig(baseConfig, 'features', 'features--copy')
|
|
56
|
+
const keys = result.sections.map((s: any) => s.key)
|
|
57
|
+
expect(keys).toEqual(['hero', 'features', 'features--copy', 'cta'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('copy is enabled by default', () => {
|
|
61
|
+
const result = duplicateInPageConfig(baseConfig, 'hero', 'hero--copy')
|
|
62
|
+
const copy = result.sections.find((s: any) => s.key === 'hero--copy')
|
|
63
|
+
expect(copy?.enabled).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reassigns consecutive order values after insertion', () => {
|
|
67
|
+
const result = duplicateInPageConfig(baseConfig, 'features', 'features--copy')
|
|
68
|
+
const orders = result.sections.map((s: any) => s.order)
|
|
69
|
+
expect(orders).toEqual([0, 1, 2, 3])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not mutate the original config', () => {
|
|
73
|
+
duplicateInPageConfig(baseConfig, 'hero', 'hero--copy')
|
|
74
|
+
expect(baseConfig.sections).toHaveLength(3)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns config unchanged when original key not found', () => {
|
|
78
|
+
const result = duplicateInPageConfig(baseConfig, 'nonexistent', 'nonexistent--copy')
|
|
79
|
+
expect(result.sections).toHaveLength(3)
|
|
80
|
+
expect(result.sections.map((s: any) => s.key)).toEqual(['hero', 'features', 'cta'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('preserves type field from original entry when present', () => {
|
|
84
|
+
const configWithType = {
|
|
85
|
+
sections: [
|
|
86
|
+
{ key: 'hero--about', type: 'hero', enabled: true, order: 0 },
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
const result = duplicateInPageConfig(configWithType, 'hero--about', 'hero--about--copy')
|
|
90
|
+
const copy = result.sections.find((s: any) => s.key === 'hero--about--copy')
|
|
91
|
+
expect(copy?.type).toBe('hero')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('sets type to original key when original has no explicit type (regression: copy shows no schema and not in preview)', () => {
|
|
95
|
+
// Sections without explicit "type" use key as type (backward-compat).
|
|
96
|
+
// Example: { key: 'testPricing' } — type resolves to 'testPricing'.
|
|
97
|
+
// The copy gets key 'testPricing--copy'. WITHOUT an explicit type,
|
|
98
|
+
// getSectionDef looks up 'testPricing--copy' in the catalog → not found → empty editor.
|
|
99
|
+
// resolveSectionType returns 'testPricing--copy' → no component → not in preview.
|
|
100
|
+
const configNoType = {
|
|
101
|
+
sections: [
|
|
102
|
+
{ key: 'testPricing', enabled: true, order: 0 },
|
|
103
|
+
],
|
|
104
|
+
}
|
|
105
|
+
const result = duplicateInPageConfig(configNoType, 'testPricing', 'testPricing--copy')
|
|
106
|
+
const copy = result.sections.find((s: any) => s.key === 'testPricing--copy')
|
|
107
|
+
// Must have explicit type = original key so catalog lookup works
|
|
108
|
+
expect(copy?.type).toBe('testPricing')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// 3. commit-pending: empty sections array (delete-only commit)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe('commit-pending: empty sections (delete-only)', () => {
|
|
117
|
+
/**
|
|
118
|
+
* Simulates the validation logic in section-commit-pending.ts.
|
|
119
|
+
* Before the fix: sections.length === 0 caused a 400.
|
|
120
|
+
* After the fix: empty sections is valid as long as pageKey + pageConfig are present.
|
|
121
|
+
*/
|
|
122
|
+
function validateCommitBody(body: {
|
|
123
|
+
pageKey?: string
|
|
124
|
+
pageConfig?: object
|
|
125
|
+
sections?: unknown
|
|
126
|
+
}): { valid: boolean; error?: string } {
|
|
127
|
+
const { pageKey, pageConfig, sections } = body
|
|
128
|
+
if (!pageKey || !pageConfig || !Array.isArray(sections)) {
|
|
129
|
+
return { valid: false, error: 'pageKey, pageConfig, and sections are required' }
|
|
130
|
+
}
|
|
131
|
+
return { valid: true }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
it('rejects missing pageKey', () => {
|
|
135
|
+
const r = validateCommitBody({ pageConfig: {}, sections: [] })
|
|
136
|
+
expect(r.valid).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('rejects missing pageConfig', () => {
|
|
140
|
+
const r = validateCommitBody({ pageKey: 'index', sections: [] })
|
|
141
|
+
expect(r.valid).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('rejects non-array sections', () => {
|
|
145
|
+
const r = validateCommitBody({ pageKey: 'index', pageConfig: {}, sections: 'nope' })
|
|
146
|
+
expect(r.valid).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('accepts empty sections array (delete-only commit)', () => {
|
|
150
|
+
const r = validateCommitBody({ pageKey: 'index', pageConfig: { sections: [] }, sections: [] })
|
|
151
|
+
expect(r.valid).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('accepts non-empty sections array (add commit)', () => {
|
|
155
|
+
const r = validateCommitBody({
|
|
156
|
+
pageKey: 'index',
|
|
157
|
+
pageConfig: { sections: [{ key: 'hero', enabled: true }] },
|
|
158
|
+
sections: [{ key: 'hero', content: { heading: 'Hello' } }],
|
|
159
|
+
})
|
|
160
|
+
expect(r.valid).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// 4. commit-pending: commit message generation
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
describe('commit-pending: commit message', () => {
|
|
169
|
+
function buildCommitMessage(pageKey: string, sections: Array<{ key: string }>): string {
|
|
170
|
+
const parts: string[] = []
|
|
171
|
+
if (sections.length > 0) {
|
|
172
|
+
const keys = sections.map(s => s.key).join(', ')
|
|
173
|
+
parts.push(`add ${sections.length} section${sections.length > 1 ? 's' : ''} (${keys})`)
|
|
174
|
+
}
|
|
175
|
+
return `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('describes added sections', () => {
|
|
179
|
+
const msg = buildCommitMessage('index', [{ key: 'hero' }, { key: 'cta' }])
|
|
180
|
+
expect(msg).toBe('content: add 2 sections (hero, cta) on index')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('uses singular for one section', () => {
|
|
184
|
+
const msg = buildCommitMessage('index', [{ key: 'hero' }])
|
|
185
|
+
expect(msg).toBe('content: add 1 section (hero) on index')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('falls back to "update page config" for delete-only (empty sections)', () => {
|
|
189
|
+
const msg = buildCommitMessage('index', [])
|
|
190
|
+
expect(msg).toBe('content: update page config on index')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('includes page key in message', () => {
|
|
194
|
+
const msg = buildCommitMessage('docs/architecture', [])
|
|
195
|
+
expect(msg).toContain('on docs/architecture')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// 5. prepare-copy: type resolution (inline simulation)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('prepare-copy: type resolution', () => {
|
|
204
|
+
/**
|
|
205
|
+
* Simulates the logic in section-prepare-copy.ts that resolves the section
|
|
206
|
+
* type from the page config entry. For multi-instance sections (hero--about),
|
|
207
|
+
* the type field is 'hero', not 'hero--about'.
|
|
208
|
+
*/
|
|
209
|
+
function resolveType(
|
|
210
|
+
sections: Array<{ key: string; type?: string }>,
|
|
211
|
+
sectionKey: string,
|
|
212
|
+
): string {
|
|
213
|
+
const entry = sections.find(s => s.key === sectionKey)
|
|
214
|
+
return entry?.type ?? sectionKey
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('returns key as type for regular sections', () => {
|
|
218
|
+
expect(resolveType([{ key: 'hero' }], 'hero')).toBe('hero')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns type field for multi-instance sections', () => {
|
|
222
|
+
expect(resolveType([{ key: 'hero--about', type: 'hero' }], 'hero--about')).toBe('hero')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('falls back to key when type field absent on multi-instance', () => {
|
|
226
|
+
expect(resolveType([{ key: 'hero--about' }], 'hero--about')).toBe('hero--about')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('returns key when section not found in config', () => {
|
|
230
|
+
expect(resolveType([], 'hero')).toBe('hero')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for deploy-hook.ts API route.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. No session cookie → 401
|
|
6
|
+
* 2. No deployHook configured → { skipped: true }
|
|
7
|
+
* 3. Successful deploy hook call → { ok: true }
|
|
8
|
+
* 4. Hook responds with 4xx → { ok: false, status }
|
|
9
|
+
* 5. Hook throws network error → { ok: false, error }
|
|
10
|
+
* 6. Secret header is sent when configured
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
|
|
15
|
+
// We import the POST handler directly; Astro's APIRoute type is just a function.
|
|
16
|
+
// We construct a minimal mock context manually.
|
|
17
|
+
|
|
18
|
+
// Inline type to avoid Astro peer dep resolution in test env
|
|
19
|
+
type MockContext = {
|
|
20
|
+
cookies: { get: (name: string) => { value: string } | undefined }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('deploy-hook POST handler', () => {
|
|
24
|
+
let POST: (ctx: MockContext) => Promise<Response>
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
// Reset modules so globalThis state doesn't leak between tests
|
|
28
|
+
vi.resetModules()
|
|
29
|
+
const mod = await import('../deploy-hook.js')
|
|
30
|
+
POST = mod.POST as unknown as typeof POST
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
// Clean up global state
|
|
35
|
+
delete (globalThis as any).__SETZKASTEN_CONFIG__
|
|
36
|
+
vi.restoreAllMocks()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function makeCtx(sessionValue?: string): MockContext {
|
|
40
|
+
return {
|
|
41
|
+
cookies: {
|
|
42
|
+
get: (name: string) =>
|
|
43
|
+
name === 'setzkasten_session' && sessionValue
|
|
44
|
+
? { value: sessionValue }
|
|
45
|
+
: undefined,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it('no session → 401', async () => {
|
|
51
|
+
const res = await POST(makeCtx())
|
|
52
|
+
expect(res.status).toBe(401)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('no deployHook configured → skipped', async () => {
|
|
56
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {}
|
|
57
|
+
const res = await POST(makeCtx('tok'))
|
|
58
|
+
expect(res.status).toBe(200)
|
|
59
|
+
const body = await res.json()
|
|
60
|
+
expect(body.skipped).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('successful hook call → { ok: true }', async () => {
|
|
64
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {
|
|
65
|
+
deployHook: { url: 'https://example.com/hook' },
|
|
66
|
+
}
|
|
67
|
+
vi.stubGlobal(
|
|
68
|
+
'fetch',
|
|
69
|
+
vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const res = await POST(makeCtx('tok'))
|
|
73
|
+
expect(res.status).toBe(200)
|
|
74
|
+
const body = await res.json()
|
|
75
|
+
expect(body.ok).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('hook responds with 4xx → { ok: false, status }', async () => {
|
|
79
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {
|
|
80
|
+
deployHook: { url: 'https://example.com/hook' },
|
|
81
|
+
}
|
|
82
|
+
vi.stubGlobal(
|
|
83
|
+
'fetch',
|
|
84
|
+
vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' } as Response),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const res = await POST(makeCtx('tok'))
|
|
88
|
+
expect(res.status).toBe(200)
|
|
89
|
+
const body = await res.json()
|
|
90
|
+
expect(body.ok).toBe(false)
|
|
91
|
+
expect(body.status).toBe(403)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('network error → { ok: false, error }', async () => {
|
|
95
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {
|
|
96
|
+
deployHook: { url: 'https://example.com/hook' },
|
|
97
|
+
}
|
|
98
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')))
|
|
99
|
+
|
|
100
|
+
const res = await POST(makeCtx('tok'))
|
|
101
|
+
expect(res.status).toBe(200)
|
|
102
|
+
const body = await res.json()
|
|
103
|
+
expect(body.ok).toBe(false)
|
|
104
|
+
expect(body.error).toContain('ECONNREFUSED')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('secret is sent as X-Setzkasten-Secret header', async () => {
|
|
108
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {
|
|
109
|
+
deployHook: { url: 'https://example.com/hook', secret: 'mysecret' },
|
|
110
|
+
}
|
|
111
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response)
|
|
112
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
113
|
+
|
|
114
|
+
await POST(makeCtx('tok'))
|
|
115
|
+
|
|
116
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
117
|
+
const headers = init.headers as Record<string, string>
|
|
118
|
+
expect(headers['X-Setzkasten-Secret']).toBe('mysecret')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('no secret configured → no X-Setzkasten-Secret header', async () => {
|
|
122
|
+
;(globalThis as any).__SETZKASTEN_CONFIG__ = {
|
|
123
|
+
deployHook: { url: 'https://example.com/hook' },
|
|
124
|
+
}
|
|
125
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response)
|
|
126
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
127
|
+
|
|
128
|
+
await POST(makeCtx('tok'))
|
|
129
|
+
|
|
130
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
131
|
+
const headers = init.headers as Record<string, string>
|
|
132
|
+
expect(headers['X-Setzkasten-Secret']).toBeUndefined()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for patchPageFile() in init-add-section.ts
|
|
3
|
+
*
|
|
4
|
+
* patchPageFile() is called on every section adoption. It:
|
|
5
|
+
* 1. Inserts an import statement for the new component
|
|
6
|
+
* 2. Adds the section key to the SECTION_COMPONENTS registry
|
|
7
|
+
* 3. Removes any hardcoded <ComponentName /> from the template
|
|
8
|
+
*
|
|
9
|
+
* Bugs here mean sections either don't appear in the page or appear twice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import { patchPageFile, calculateRelativePath } from '../init-add-section'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Fixtures
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const PAGE_WITH_SECTIONS = `---
|
|
20
|
+
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
21
|
+
import HeroSection from '../../components/sections/HeroSection.astro'
|
|
22
|
+
import { getSection } from 'setzkasten:content'
|
|
23
|
+
|
|
24
|
+
const normalize = (k) => k.replace(/[-_]/g, '').toLowerCase()
|
|
25
|
+
const SECTION_COMPONENTS = {
|
|
26
|
+
[normalize('hero')]: HeroSection,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const page = getPage('index')
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<BaseLayout>
|
|
33
|
+
{page?.sections.map(({ key, data }) => {
|
|
34
|
+
const Component = SECTION_COMPONENTS[normalize(key)]
|
|
35
|
+
if (!Component) return null
|
|
36
|
+
return <Component data={data} />
|
|
37
|
+
})}
|
|
38
|
+
</BaseLayout>
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
// Page where the import exists but the registry is empty (new adoption: registry add + tag removal)
|
|
42
|
+
const PAGE_WITH_HARDCODED_COMPONENT = `---
|
|
43
|
+
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
44
|
+
import HeroSection from '../../components/sections/HeroSection.astro'
|
|
45
|
+
import { getSection } from 'setzkasten:content'
|
|
46
|
+
|
|
47
|
+
const normalize = (k) => k.replace(/[-_]/g, '').toLowerCase()
|
|
48
|
+
const SECTION_COMPONENTS = {
|
|
49
|
+
[normalize('features')]: FeaturesSection,
|
|
50
|
+
}
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<BaseLayout>
|
|
54
|
+
<HeroSection />
|
|
55
|
+
</BaseLayout>
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 1. Basic import injection and registry entry
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('patchPageFile — new section injection', () => {
|
|
63
|
+
const pagePath = 'src/pages/index.astro'
|
|
64
|
+
const componentPath = 'src/components/sections/FeaturesSection.astro'
|
|
65
|
+
|
|
66
|
+
it('should return a non-null result', () => {
|
|
67
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'features', 'FeaturesSection', componentPath, pagePath)
|
|
68
|
+
expect(result).not.toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should add the import statement', () => {
|
|
72
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'features', 'FeaturesSection', componentPath, pagePath)!
|
|
73
|
+
expect(result).toContain("import FeaturesSection from")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should include the correct relative path', () => {
|
|
77
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'features', 'FeaturesSection', componentPath, pagePath)!
|
|
78
|
+
// From src/pages to src/components/sections → one level up → ../components/sections/...
|
|
79
|
+
expect(result).toContain('../components/sections/FeaturesSection.astro')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should add the section key to SECTION_COMPONENTS', () => {
|
|
83
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'features', 'FeaturesSection', componentPath, pagePath)!
|
|
84
|
+
expect(result).toContain('features')
|
|
85
|
+
expect(result).toContain('FeaturesSection')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should preserve existing sections in the registry', () => {
|
|
89
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'features', 'FeaturesSection', componentPath, pagePath)!
|
|
90
|
+
expect(result).toContain('HeroSection')
|
|
91
|
+
expect(result).toContain("normalize('hero')")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// 2. Idempotency — same section adopted twice
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('patchPageFile — duplicate prevention', () => {
|
|
100
|
+
const pagePath = 'src/pages/index.astro'
|
|
101
|
+
const componentPath = 'src/components/sections/HeroSection.astro'
|
|
102
|
+
|
|
103
|
+
it('should return null if section is already registered', () => {
|
|
104
|
+
// HeroSection with key 'hero' is already in PAGE_WITH_SECTIONS
|
|
105
|
+
const result = patchPageFile(PAGE_WITH_SECTIONS, 'hero', 'HeroSection', componentPath, pagePath)
|
|
106
|
+
// Either null (no change) or source with only one hero entry
|
|
107
|
+
if (result !== null) {
|
|
108
|
+
const heroCount = (result.match(/normalize\('hero'\)/g) ?? []).length
|
|
109
|
+
expect(heroCount).toBe(1)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// 3. Hardcoded component removal
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('patchPageFile — hardcoded tag removal', () => {
|
|
119
|
+
const pagePath = 'src/pages/index.astro'
|
|
120
|
+
const componentPath = 'src/components/sections/HeroSection.astro'
|
|
121
|
+
|
|
122
|
+
it('should remove the hardcoded <HeroSection /> when adding to registry', () => {
|
|
123
|
+
// PAGE_WITH_HARDCODED_COMPONENT has HeroSection imported but NOT in registry
|
|
124
|
+
// → patchPageFile adds it to registry AND removes the hardcoded tag
|
|
125
|
+
const result = patchPageFile(PAGE_WITH_HARDCODED_COMPONENT, 'hero', 'HeroSection', componentPath, pagePath)
|
|
126
|
+
expect(result).not.toBeNull()
|
|
127
|
+
expect(result).not.toMatch(/<HeroSection\s*\/>/)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should add the section to SECTION_COMPONENTS when removing hardcoded tag', () => {
|
|
131
|
+
const result = patchPageFile(PAGE_WITH_HARDCODED_COMPONENT, 'hero', 'HeroSection', componentPath, pagePath)!
|
|
132
|
+
expect(result).toContain('hero')
|
|
133
|
+
expect(result).toContain('HeroSection')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 4. Page at a nested path — import depth
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
describe('patchPageFile — nested page paths', () => {
|
|
142
|
+
const PAGE_NESTED = `---
|
|
143
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
|
144
|
+
import HeroSection from '../../../components/sections/HeroSection.astro'
|
|
145
|
+
import { getSection } from 'setzkasten:content'
|
|
146
|
+
|
|
147
|
+
const normalize = (k) => k.replace(/[-_]/g, '').toLowerCase()
|
|
148
|
+
const SECTION_COMPONENTS = {
|
|
149
|
+
[normalize('hero')]: HeroSection,
|
|
150
|
+
}
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
<BaseLayout />
|
|
154
|
+
`
|
|
155
|
+
|
|
156
|
+
it('should calculate correct import depth for nested page', () => {
|
|
157
|
+
const pagePath = 'src/pages/docs/architecture.astro'
|
|
158
|
+
const componentPath = 'src/components/sections/FeaturesSection.astro'
|
|
159
|
+
|
|
160
|
+
const result = patchPageFile(PAGE_NESTED, 'features', 'FeaturesSection', componentPath, pagePath)
|
|
161
|
+
if (result) {
|
|
162
|
+
// From src/pages/docs to src/components/sections = ../../components/sections
|
|
163
|
+
expect(result).toContain('../../components/sections/FeaturesSection.astro')
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// 5. calculateRelativePath (exported for direct testing)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('calculateRelativePath', () => {
|
|
173
|
+
it('same directory → ./file', () => {
|
|
174
|
+
expect(calculateRelativePath('src/pages', 'src/pages/Hero.astro')).toBe('./Hero.astro')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('one level up', () => {
|
|
178
|
+
expect(calculateRelativePath('src/pages', 'src/components/Hero.astro'))
|
|
179
|
+
.toBe('../components/Hero.astro')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('two levels up', () => {
|
|
183
|
+
expect(calculateRelativePath('src/pages/docs', 'src/components/sections/Hero.astro'))
|
|
184
|
+
.toBe('../../components/sections/Hero.astro')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('deeper nesting in monorepo', () => {
|
|
188
|
+
expect(calculateRelativePath(
|
|
189
|
+
'apps/website/src/pages',
|
|
190
|
+
'apps/website/src/components/sections/FooterSection.astro',
|
|
191
|
+
)).toBe('../components/sections/FooterSection.astro')
|
|
192
|
+
})
|
|
193
|
+
})
|