Skip to content

Commit c5c9686

Browse files
authored
refactor(site-import): reuse CMS media client (CoreBunch#80)
1 parent e579b4d commit c5c9686

3 files changed

Lines changed: 118 additions & 108 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, describe, expect, it } from 'bun:test'
2+
import { createSiteImportAdapter } from '@admin/modals/SiteImport/shared/createSiteImportAdapter'
3+
4+
const originalFetch = globalThis.fetch
5+
6+
afterEach(() => {
7+
globalThis.fetch = originalFetch
8+
})
9+
10+
function jsonResponse(body: unknown, status = 200): Response {
11+
return new Response(JSON.stringify(body), {
12+
status,
13+
headers: { 'content-type': 'application/json' },
14+
})
15+
}
16+
17+
describe('createSiteImportAdapter', () => {
18+
it('uploads imported assets through the CMS media client contract', async () => {
19+
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
20+
globalThis.fetch = async (input, init) => {
21+
calls.push({ input, init })
22+
const url = String(input)
23+
24+
if (url === '/admin/api/cms/media') {
25+
return jsonResponse({
26+
asset: {
27+
id: 'asset/one',
28+
filename: 'hero.png',
29+
mimeType: 'image/png',
30+
sizeBytes: 12,
31+
publicPath: '/uploads/hero.png',
32+
uploadedByUserId: null,
33+
createdAt: '2026-01-03T00:00:00.000Z',
34+
},
35+
}, 201)
36+
}
37+
38+
if (url === '/admin/api/cms/media/folders') {
39+
if (init?.method === 'GET') {
40+
return jsonResponse({ folders: [] })
41+
}
42+
return jsonResponse({
43+
folder: {
44+
id: 'folder-hero',
45+
name: 'images',
46+
slug: 'images',
47+
parentId: null,
48+
sortOrder: 0,
49+
createdByUserId: null,
50+
createdAt: '2026-01-03T00:00:00.000Z',
51+
},
52+
}, 201)
53+
}
54+
55+
if (url === '/admin/api/cms/media/asset%2Fone/folders') {
56+
return jsonResponse({
57+
asset: {
58+
id: 'asset/one',
59+
filename: 'hero.png',
60+
mimeType: 'image/png',
61+
sizeBytes: 12,
62+
publicPath: '/uploads/hero.png',
63+
uploadedByUserId: null,
64+
createdAt: '2026-01-03T00:00:00.000Z',
65+
folderIds: ['folder-hero'],
66+
},
67+
})
68+
}
69+
70+
return jsonResponse({ error: `Unexpected request: ${url}` }, 500)
71+
}
72+
73+
const adapter = createSiteImportAdapter({ sessionId: 'test-session' })
74+
await expect(adapter.uploadAsset({
75+
path: 'images/hero.png',
76+
bytes: new Uint8Array([1, 2, 3]),
77+
mimeType: 'image/png',
78+
})).resolves.toBe('/uploads/hero.png')
79+
80+
expect(calls).toHaveLength(4)
81+
expect(calls.map((call) => String(call.input))).toEqual([
82+
'/admin/api/cms/media',
83+
'/admin/api/cms/media/folders',
84+
'/admin/api/cms/media/folders',
85+
'/admin/api/cms/media/asset%2Fone/folders',
86+
])
87+
for (const call of calls) {
88+
expect(call.init?.credentials).toBe('include')
89+
}
90+
expect(calls[0].init?.body).toBeInstanceOf(FormData)
91+
expect(calls[2].init?.body).toBe(JSON.stringify({ name: 'images', parentId: null }))
92+
expect(calls[3].init?.body).toBe(JSON.stringify({ add: ['folder-hero'] }))
93+
})
94+
})

src/__tests__/architecture/boundary-validation.test.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,7 @@ const ALLOWLIST_FETCH = new Set<string>([
133133
// tool-result POSTs, must use apiRequest.
134134
join(PROJECT_ROOT, 'src/admin/pages/site/agent/agentSlice.ts'),
135135

136-
// §3.2 createSiteImportAdapter.ts — four media/folder API calls that pre-date
137-
// the apiRequest migration and are legitimately using parseJsonResponse (not
138-
// res.json() as) for TypeBox validation at the boundary. The FormData binary-
139-
// blob upload (MIME-multipart) and the non-fatal fire-and-forget folder-
140-
// placement call make migrating to apiRequest a non-trivial plumbing change.
141-
// All four calls satisfy constraint #272 via parseJsonResponse; migration to
142-
// apiRequest should happen in a dedicated cleanup but is not a boundary-
143-
// validation regression.
144-
join(PROJECT_ROOT, 'src/admin/modals/SiteImport/shared/createSiteImportAdapter.ts'),
145-
146-
// §3.3 SvgControl.tsx — fetches the raw bytes of a user-picked .svg asset
136+
// §3.2 SvgControl.tsx — fetches the raw bytes of a user-picked .svg asset
147137
// from its public path (asset.publicPath) to inline its markup. This is a
148138
// plain file-content GET, not a JSON-envelope CMS endpoint, so apiRequest
149139
// (which validates a TypeBox success body) cannot model it — the response

src/admin/modals/SiteImport/shared/createSiteImportAdapter.ts

Lines changed: 23 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -7,55 +7,18 @@
77
* atomic Immer producer → single Cmd+Z undo step.
88
*/
99

10-
import { Type, type Static } from '@sinclair/typebox'
1110
import type { SiteImportAdapter, SiteImportTransaction } from '@core/siteImport'
12-
import { parseJsonResponse } from '@core/utils/jsonValidate'
13-
import { responseErrorMessage } from '@core/http'
1411
import { installCmsGoogleFont } from '@core/persistence/cmsFonts'
12+
import {
13+
createCmsMediaFolder,
14+
listCmsMediaFolders,
15+
setCmsMediaAssetFolders,
16+
uploadCmsMediaAsset,
17+
type CmsMediaFolder,
18+
} from '@core/persistence/cmsMedia'
19+
import { getErrorMessage } from '@core/utils/errorMessage'
1520
import { useEditorStore } from '@site/store/store'
1621

17-
// Minimal TypeBox schema for the upload response — both `id` and `publicPath`
18-
// are needed: id to assign the asset to its destination folder, publicPath to
19-
// stitch back into the imported HTML/CSS so references resolve.
20-
const MediaUploadResponseSchema = Type.Object(
21-
{
22-
asset: Type.Object(
23-
{ id: Type.String(), publicPath: Type.String() },
24-
{ additionalProperties: true },
25-
),
26-
},
27-
{ additionalProperties: true },
28-
)
29-
30-
// Subset of the folder list response — name / parentId let us match against an
31-
// existing folder tree so a re-run of the wizard re-uses the same folders
32-
// instead of accumulating `assets-2`, `assets-3` next to the originals.
33-
const MediaFolderListResponseSchema = Type.Object(
34-
{
35-
folders: Type.Array(
36-
Type.Object(
37-
{
38-
id: Type.String(),
39-
name: Type.String(),
40-
parentId: Type.Union([Type.String(), Type.Null()]),
41-
},
42-
{ additionalProperties: true },
43-
),
44-
),
45-
},
46-
{ additionalProperties: true },
47-
)
48-
49-
const MediaFolderCreateResponseSchema = Type.Object(
50-
{
51-
folder: Type.Object(
52-
{ id: Type.String() },
53-
{ additionalProperties: true },
54-
),
55-
},
56-
{ additionalProperties: true },
57-
)
58-
5922
interface AdapterCallbacks {
6023
/** Stable id for the upload session (for logging). */
6124
sessionId: string
@@ -81,20 +44,14 @@ function dirSegments(path: string): string[] {
8144
return dir.split('/').filter((s) => s.length > 0)
8245
}
8346

84-
interface FolderIndexEntry {
85-
id: string
86-
parentId: string | null
87-
name: string
88-
}
89-
9047
/**
9148
* In-memory cache of folder ids keyed by their full slash-delimited path
9249
* (`'assets'`, `'assets/img'`, …). Folders the user already has from
9350
* previous imports / manual uploads are matched against this index so a
9451
* second wizard run doesn't duplicate the tree.
9552
*/
96-
function buildFolderIndex(folders: ReadonlyArray<FolderIndexEntry>): Map<string, string> {
97-
const byId = new Map<string, FolderIndexEntry>()
53+
function buildFolderIndex(folders: ReadonlyArray<CmsMediaFolder>): Map<string, string> {
54+
const byId = new Map<string, CmsMediaFolder>()
9855
for (const f of folders) byId.set(f.id, f)
9956

10057
// Resolve each folder's full path by walking parents.
@@ -128,14 +85,7 @@ export function createSiteImportAdapter(opts: AdapterCallbacks): SiteImportAdapt
12885
if (folderIndex) return folderIndex
12986
if (!folderIndexPromise) {
13087
folderIndexPromise = (async () => {
131-
const res = await fetch('/admin/api/cms/media/folders', { method: 'GET' })
132-
if (!res.ok) {
133-
throw new Error(
134-
`[siteImportAdapter] Could not load folder index: ${await responseErrorMessage(res, 'Folder list failed')}`,
135-
)
136-
}
137-
const payload = await parseJsonResponse(res, MediaFolderListResponseSchema)
138-
const idx = buildFolderIndex(payload.folders)
88+
const idx = buildFolderIndex(await listCmsMediaFolders())
13989
folderIndex = idx
14090
return idx
14191
})()
@@ -164,40 +114,22 @@ export function createSiteImportAdapter(opts: AdapterCallbacks): SiteImportAdapt
164114
parentId = cached
165115
continue
166116
}
167-
// Explicit annotations break a TS inference cycle: `parentId` is
168-
// reassigned from `payload.folder.id`, and without the annotation the
169-
// compiler tries to resolve `payload`'s (and `res`'s) types through the
170-
// captured `parentId` recursively (TS7022).
171-
const res: Response = await fetch('/admin/api/cms/media/folders', {
172-
method: 'POST',
173-
headers: { 'content-type': 'application/json' },
174-
body: JSON.stringify({ name: segment, parentId }),
175-
})
176-
if (!res.ok) {
177-
throw new Error(
178-
`[siteImportAdapter] Folder create failed for "${cumulative}": ${await responseErrorMessage(res, 'Create folder failed')}`,
179-
)
180-
}
181-
const payload: Static<typeof MediaFolderCreateResponseSchema> =
182-
await parseJsonResponse(res, MediaFolderCreateResponseSchema)
183-
index.set(cumulative, payload.folder.id)
184-
parentId = payload.folder.id
117+
const folder = await createCmsMediaFolder({ name: segment, parentId })
118+
index.set(cumulative, folder.id)
119+
parentId = folder.id
185120
}
186121
return parentId
187122
}
188123

189124
async function assignAssetToFolder(assetId: string, folderId: string): Promise<void> {
190-
const res = await fetch(`/admin/api/cms/media/${assetId}/folders`, {
191-
method: 'POST',
192-
headers: { 'content-type': 'application/json' },
193-
body: JSON.stringify({ add: [folderId] }),
194-
})
195-
if (!res.ok) {
125+
try {
126+
await setCmsMediaAssetFolders(assetId, { add: [folderId] })
127+
} catch (err) {
196128
// Surface as a non-fatal log: the asset uploaded fine, only the
197129
// folder placement failed. The user can drag it to the right folder
198130
// by hand afterwards.
199131
console.warn(
200-
`[siteImportAdapter] Asset ${assetId} placed at the media root; folder assign returned ${res.status}.`,
132+
`[siteImportAdapter] Asset ${assetId} placed at the media root; ${getErrorMessage(err, 'folder assignment failed')}.`,
201133
)
202134
}
203135
}
@@ -209,17 +141,11 @@ export function createSiteImportAdapter(opts: AdapterCallbacks): SiteImportAdapt
209141

210142
async uploadAsset({ path, bytes, mimeType }) {
211143
opts.onUploadStart?.({ path })
212-
const form = new FormData()
213144
// bytes comes from fflate/File APIs — always backed by a plain ArrayBuffer.
214145
// TypeScript's BlobPart constraint excludes SharedArrayBuffer; the cast is safe.
215146
const blobData: ArrayBuffer = bytes.slice().buffer as ArrayBuffer
216-
form.append('file', new Blob([blobData], { type: mimeType }), basename(path))
217-
const res = await fetch('/admin/api/cms/media', { method: 'POST', body: form })
218-
if (!res.ok) {
219-
const errMsg = await responseErrorMessage(res, 'Upload failed')
220-
throw new Error(`[siteImportAdapter] Upload failed for ${path}: ${errMsg}`)
221-
}
222-
const payload = await parseJsonResponse(res, MediaUploadResponseSchema)
147+
const file = new File([blobData], basename(path), { type: mimeType })
148+
const asset = await uploadCmsMediaAsset(file)
223149

224150
// Place the asset under a folder that mirrors its source bundle path.
225151
// Folder creation happens lazily here so a flat bundle (every asset at
@@ -230,11 +156,11 @@ export function createSiteImportAdapter(opts: AdapterCallbacks): SiteImportAdapt
230156
const segments = dirSegments(path)
231157
if (segments.length > 0) {
232158
const folderId = await ensureFolderPath(segments)
233-
if (folderId) await assignAssetToFolder(payload.asset.id, folderId)
159+
if (folderId) await assignAssetToFolder(asset.id, folderId)
234160
}
235161

236-
opts.onUploadComplete?.({ path, url: payload.asset.publicPath })
237-
return payload.asset.publicPath
162+
opts.onUploadComplete?.({ path, url: asset.publicPath })
163+
return asset.publicPath
238164
},
239165

240166
async commit(recipe) {

0 commit comments

Comments
 (0)