fix: render prompt museum media
This commit is contained in:
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
fetchPromptMuseumEntry,
|
||||
fetchPromptMuseumMedia,
|
||||
fetchPromptMuseumPage,
|
||||
PromptMuseumApiError,
|
||||
} from '@/lib/image-prompt-museum';
|
||||
@@ -65,4 +66,38 @@ describe('Prompt Museum renderer API boundary', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads a controlled relative media path through Main and creates a data URL', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/webp', dataBase64: 'AQID' });
|
||||
|
||||
await expect(fetchPromptMuseumMedia(
|
||||
'/api/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
)).resolves.toBe('data:image/webp;base64,AQID');
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://cdn.example/image.webp',
|
||||
'/api/image-prompt-museum/../media/thumbnail',
|
||||
'/api/image-prompt-museum/entry/media/original',
|
||||
])('rejects unsafe media URL %s before IPC', async (url) => {
|
||||
await expect(fetchPromptMuseumMedia(url)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
code: 'PROMPT_MUSEUM_INVALID_MEDIA_URL',
|
||||
});
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an untrusted Main media payload', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'text/html', dataBase64: 'PGgxPg==' });
|
||||
|
||||
await expect(fetchPromptMuseumMedia(
|
||||
'/api/image-prompt-museum/entry/media/0',
|
||||
)).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PromptMuseumEntry, PromptMuseumPage } from '../../shared/image-pro
|
||||
|
||||
const fetchPromptMuseumPageMock = vi.hoisted(() => vi.fn());
|
||||
const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn());
|
||||
const fetchPromptMuseumMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-prompt-museum')>();
|
||||
@@ -14,6 +15,7 @@ vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args),
|
||||
fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args),
|
||||
fetchPromptMuseumMedia: (...args: unknown[]) => fetchPromptMuseumMediaMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,6 +68,7 @@ describe('Prompt Museum page', () => {
|
||||
useImagePromptMuseumStore.getState().clearPendingPrompt();
|
||||
fetchPromptMuseumPageMock.mockResolvedValue(pageFixture);
|
||||
fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture);
|
||||
fetchPromptMuseumMediaMock.mockResolvedValue('data:image/webp;base64,AQID');
|
||||
});
|
||||
|
||||
it('keeps the inspiration filters compact and below the native title bar', async () => {
|
||||
@@ -126,4 +129,78 @@ describe('Prompt Museum page', () => {
|
||||
expect.objectContaining({ cursor: 'cursor-two' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders source attribution as text when the server omits its optional URL', async () => {
|
||||
fetchPromptMuseumEntryMock.mockResolvedValueOnce({
|
||||
...entryFixture,
|
||||
attribution: {
|
||||
...entryFixture.attribution,
|
||||
source: { name: 'PromptHero' },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 编辑感产品海报' }));
|
||||
expect(await screen.findByText('PromptHero')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'PromptHero' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads relative media through Main and renders its data URL', async () => {
|
||||
fetchPromptMuseumPageMock.mockResolvedValueOnce({
|
||||
...pageFixture,
|
||||
items: [{
|
||||
...pageFixture.items[0],
|
||||
thumbnail: {
|
||||
...pageFixture.items[0].thumbnail,
|
||||
url: '/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '编辑感产品海报示例' });
|
||||
expect(image).toHaveAttribute('src', 'data:image/webp;base64,AQID');
|
||||
expect(fetchPromptMuseumMediaMock).toHaveBeenCalledWith(
|
||||
'/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a card usable when its relative image fails', async () => {
|
||||
fetchPromptMuseumMediaMock.mockRejectedValueOnce(new Error('image unavailable'));
|
||||
fetchPromptMuseumPageMock.mockResolvedValueOnce({
|
||||
...pageFixture,
|
||||
items: [{
|
||||
...pageFixture.items[0],
|
||||
thumbnail: {
|
||||
...pageFixture.items[0].thumbnail,
|
||||
url: '/api/image-prompt-museum/prompt-one/media/thumbnail',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-prompts']}>
|
||||
<Routes>
|
||||
<Route path="/image-prompts" element={<ImagePromptMuseum />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('编辑感产品海报示例加载失败')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '查看 编辑感产品海报' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum';
|
||||
import type { PromptMuseumCard } from '../../shared/image-prompt-museum';
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
@@ -19,6 +20,26 @@ function createResponse() {
|
||||
};
|
||||
}
|
||||
|
||||
function card(imageUrl = '/api/image-prompt-museum/prompt-one/media/thumbnail'): PromptMuseumCard {
|
||||
return {
|
||||
id: 'prompt-one',
|
||||
slug: 'prompt-one',
|
||||
title: '示例',
|
||||
summary: '示例摘要',
|
||||
thumbnail: { url: imageUrl, width: 1200, height: 900, alt: '示例' },
|
||||
categories: [],
|
||||
model: { id: 'image-model', name: 'Image Model' },
|
||||
language: 'zh-CN',
|
||||
attribution: {
|
||||
author: { name: '作者' },
|
||||
source: { name: '来源', url: 'https://example.com/source' },
|
||||
license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' },
|
||||
},
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Prompt Museum Main route boundary', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
@@ -74,6 +95,165 @@ describe('Prompt Museum Main route boundary', () => {
|
||||
expect(response.json).toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
it('accepts controlled relative media URLs in list and detail DTOs', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [card()],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
...card(),
|
||||
prompt: 'Create an image',
|
||||
variables: [],
|
||||
images: [{ url: '/api/image-prompt-museum/prompt-one/media/0', width: 1200, height: 900, alt: '详情' }],
|
||||
requiresReferenceImages: false,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const listResponse = createResponse();
|
||||
const detailResponse = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
listResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
||||
{} as never,
|
||||
);
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
detailResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(listResponse.json).toMatchObject({
|
||||
success: true,
|
||||
data: { items: [{ thumbnail: { url: '/api/image-prompt-museum/prompt-one/media/thumbnail' } }] },
|
||||
});
|
||||
expect(detailResponse.json).toMatchObject({
|
||||
success: true,
|
||||
data: { images: [{ url: '/api/image-prompt-museum/prompt-one/media/0' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts list attribution sources with a missing or null optional URL', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
const itemWithoutUrl = card();
|
||||
itemWithoutUrl.attribution.source = { name: 'PromptHero' };
|
||||
const itemWithNullUrl = card();
|
||||
itemWithNullUrl.id = 'prompt-two';
|
||||
itemWithNullUrl.attribution.source = { name: 'Community archive', url: null };
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [itemWithoutUrl, itemWithNullUrl],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.json).toMatchObject({
|
||||
success: true,
|
||||
data: { items: [
|
||||
{ attribution: { source: { name: 'PromptHero' } } },
|
||||
{ attribution: { source: { name: 'Community archive', url: null } } },
|
||||
] },
|
||||
});
|
||||
const sources = (response.json.data as { items: Array<{ attribution: { source: unknown } }> })
|
||||
.items.map((item) => item.attribution.source);
|
||||
expect(sources).toEqual([
|
||||
{ name: 'PromptHero' },
|
||||
{ name: 'Community archive', url: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('proxies a fixed media path with Bearer auth and returns only bounded raster data', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png; charset=binary', 'Content-Length': '3' },
|
||||
}));
|
||||
const handler = createImagePromptMuseumRouteHandler({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/entry-1:en/media/thumbnail'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/image-prompt-museum/entry-1:en/media/thumbnail',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer works-token' }),
|
||||
redirect: 'manual',
|
||||
}),
|
||||
);
|
||||
expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/png' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['non-image media', { 'Content-Type': 'text/html' }],
|
||||
['oversized media', { 'Content-Type': 'image/webp', 'Content-Length': String(10 * 1024 * 1024 + 1) }],
|
||||
])('rejects %s responses', async (_name, headers) => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response('unsafe', { status: 200, headers }));
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/0'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.res.statusCode).toBe(502);
|
||||
expect(response.json).toMatchObject({ code: 'PROMPT_MUSEUM_INVALID_RESPONSE' });
|
||||
});
|
||||
|
||||
it('does not claim unsafe or unknown media paths', async () => {
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
const unsafeResponse = createResponse();
|
||||
const unknownResponse = createResponse();
|
||||
|
||||
await expect(handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
unsafeResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/../media/thumbnail'),
|
||||
{} as never,
|
||||
)).resolves.toBe(false);
|
||||
await expect(handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
unknownResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-prompt-museum/prompt-one/media/original'),
|
||||
{} as never,
|
||||
)).resolves.toBe(false);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a stable auth error without calling the upstream service', async () => {
|
||||
getAccessToken.mockResolvedValue(null);
|
||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||
@@ -215,23 +395,7 @@ describe('Prompt Museum Main route boundary', () => {
|
||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
items: [{
|
||||
id: 'prompt-one',
|
||||
slug: 'prompt-one',
|
||||
title: '示例',
|
||||
summary: '示例摘要',
|
||||
thumbnail: { url: 'http://internal.example/secret.png', width: 1200, height: 900, alt: '示例' },
|
||||
categories: [],
|
||||
model: { id: 'image-model', name: 'Image Model' },
|
||||
language: 'zh-CN',
|
||||
attribution: {
|
||||
author: { name: '作者' },
|
||||
source: { name: '来源', url: 'https://example.com/source' },
|
||||
license: { name: 'CC BY 4.0', attributionText: '作者 / 来源 / CC BY 4.0' },
|
||||
},
|
||||
publishedAt: '2026-08-01T00:00:00Z',
|
||||
updatedAt: '2026-08-01T00:00:00Z',
|
||||
}],
|
||||
items: [card('http://internal.example/secret.png')],
|
||||
facets: { useCases: [], styles: [], subjects: [] },
|
||||
nextCursor: null,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user