fix: render prompt museum media

This commit is contained in:
brother7 committed 2026-08-18 12:32:34 +08:00
1 parent 11b19832a3
commit f8d82e6c19
9 files changed
+609 -37

No files matched your search

+181 -17
View File
@@ -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,
},