diff --git a/ui/src/lib/labsImagePick.ts b/ui/src/lib/labsImagePick.ts index 522c5d3e..fe5d9604 100644 --- a/ui/src/lib/labsImagePick.ts +++ b/ui/src/lib/labsImagePick.ts @@ -30,11 +30,15 @@ export async function fileFromOutput(item: ApiOutput): Promise { return new File([blob], item.name, { type: blob.type || 'image/png' }) } -/** Series import copies from uploads/ only. Catalog picks are copied once into uploads. */ +/** + * Copy the pick into uploads/ and return the upload API payload. + * Callers (Series import, Character Creator describe, H3 isfile checks) + * only share that absolute filesystem path. A synthetic `uploads/${name}` + * is not enough: describe resolves relative names under uploads/, so + * `uploads/hero.png` becomes `uploads/uploads/hero.png` and the default + * empty-prompt Character Creator flow 400s. + */ export async function ensureUploadsPath(item: ApiOutput): Promise<{ path: string; name: string; url: string }> { - if (isUploadOutput(item)) { - return { path: `uploads/${item.name}`, name: item.name, url: item.url } - } const uploaded = await uploadImage(await fileFromOutput(item)) return { path: uploaded.path, name: uploaded.filename, url: uploaded.url } } diff --git a/ui/tests/labsImagePick.test.mjs b/ui/tests/labsImagePick.test.mjs index 6d8135ed..a4a75dfc 100644 --- a/ui/tests/labsImagePick.test.mjs +++ b/ui/tests/labsImagePick.test.mjs @@ -12,8 +12,58 @@ const uploadItem = { thumbnail_url: '/api/v1/uploads/hero.png', } -test('upload catalog URLs stay in uploads/ without a second POST', async () => { +const catalogItem = { + name: 'shot.png', + type: 'image', + mode: 'image', + size: 20, + created_at: 2, + url: '/api/v1/file/shot.png?workspace=default', + thumbnail_url: '/api/v1/file/shot.png?workspace=default', +} + +function mockUploadFetch(expectedUrl) { + const original = globalThis.fetch + globalThis.fetch = (async (input, init) => { + const url = String(input) + if (url.includes(expectedUrl) && !init?.method) { + return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } }) + } + if (url.includes('/api/v1/upload') && init?.method === 'POST') { + return new Response(JSON.stringify({ + filename: 'copied.png', + path: '/abs/uploads/copied.png', + url: '/api/v1/uploads/copied.png', + }), { headers: { 'content-type': 'application/json' } }) + } + throw new Error(`unexpected fetch ${url}`) + }) + return () => { globalThis.fetch = original } +} + +test('upload catalog URLs still copy through /api/v1/upload so callers get an absolute path', async () => { assert.equal(isUploadOutput(uploadItem), true) - const ensured = await ensureUploadsPath(uploadItem) - assert.deepEqual(ensured, { path: 'uploads/hero.png', name: 'hero.png', url: '/api/v1/uploads/hero.png' }) + const restore = mockUploadFetch('/api/v1/uploads/hero.png') + try { + const ensured = await ensureUploadsPath(uploadItem) + assert.deepEqual(ensured, { + path: '/abs/uploads/copied.png', + name: 'copied.png', + url: '/api/v1/uploads/copied.png', + }) + } finally { + restore() + } +}) + +test('workspace catalog picks are fetched from their file URL and copied into uploads/', async () => { + assert.equal(isUploadOutput(catalogItem), false) + const restore = mockUploadFetch('/api/v1/file/shot.png?workspace=default') + try { + const ensured = await ensureUploadsPath(catalogItem) + assert.equal(ensured.path, '/abs/uploads/copied.png') + assert.equal(ensured.name, 'copied.png') + } finally { + restore() + } }) diff --git a/ui/tests/seriesLabsPicker.test.tsx b/ui/tests/seriesLabsPicker.test.tsx index a1517107..47edba0b 100644 --- a/ui/tests/seriesLabsPicker.test.tsx +++ b/ui/tests/seriesLabsPicker.test.tsx @@ -42,6 +42,16 @@ test('Series canon identity can be chosen from HocusPocus without approving the total: 1, }), { headers: { 'content-type': 'application/json' } }) } + if (url.includes('/api/v1/uploads/hero.png') && !init?.method) { + return new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'content-type': 'image/png' } }) + } + if (url.includes('/api/v1/upload') && init?.method === 'POST') { + return new Response(JSON.stringify({ + filename: 'copied.png', + path: '/abs/uploads/copied.png', + url: '/api/v1/uploads/copied.png', + }), { headers: { 'content-type': 'application/json' } }) + } if (url.includes('/assets/import') && init?.method === 'POST') { imported.push(JSON.parse(String(init.body || '{}'))) return new Response(JSON.stringify({ asset: { id: 'asset_1' }, series }), { headers: { 'content-type': 'application/json' } }) @@ -64,7 +74,7 @@ test('Series canon identity can be chosen from HocusPocus without approving the fireEvent.click(card) fireEvent.click(screen.getByRole('button', { name: 'Choose' })) await waitFor(() => assert.equal(imported.length, 1)) - assert.equal(imported[0].uploadPath, 'uploads/hero.png') + assert.equal(imported[0].uploadPath, '/abs/uploads/copied.png') assert.equal(imported[0].ownerType, 'character') assert.equal(imported[0].ownerId, character.id) assert.equal(imported[0].referenceRole, 'primary_portrait')