fix: render prompt museum media
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
# Task: Fix Makelore Prompt Museum media rendering
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260818-prompt-museum-client-4f7a
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260818-prompt-museum-client-4f7a-prompt-museum-client
|
||||||
|
- Worktree: D:\mk-4f7a
|
||||||
|
- Base commit: 11b19832a35477d2136c6ea953dd9c408fd84816
|
||||||
|
- Owner: developer
|
||||||
|
- Status: Ready for integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Permit the server-controlled Prompt Museum media URL shape in strict list/detail DTO projection.
|
||||||
|
- Add an authenticated, fixed-path Main proxy for bounded Prompt Museum raster media.
|
||||||
|
- Resolve relative media through Main in the Renderer while keeping absolute HTTPS images direct and image failures card-local.
|
||||||
|
- Preserve real attribution sources whose optional URL is missing or null without creating an undefined Renderer link.
|
||||||
|
- Cover list, detail, media, invalid-path/response, API conversion, and page success/failure behavior.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- The only relative media URL allowed is `/api/image-prompt-museum/{entry}/media/{thumbnail|number}`, with entry IDs matching `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`.
|
||||||
|
- Works Bearer credentials remain Main-owned; the existing Host API IPC JSON protocol is unchanged.
|
||||||
|
- Media is limited to 10 MiB and trusted raster MIME types; upstream payloads and errors are not exposed directly.
|
||||||
|
- Absolute credential-free HTTPS images remain directly renderable. A failed image displays a placeholder without failing its card or detail view.
|
||||||
|
- Attribution source URLs are optional: undefined is omitted, null is retained, and present strings remain strict credential-free HTTPS.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Main now accepts controlled relative media URLs in projected cards/details and proxies only the mirrored fixed local media route with Works Bearer refresh behavior.
|
||||||
|
- Main rejects non-raster or oversized responses and returns only `dataBase64` plus normalized trusted `mimeType` for successful media.
|
||||||
|
- Renderer converts relative media responses into data URLs, validates the JSON again, and preserves direct HTTPS rendering.
|
||||||
|
- Museum images load independently; pending and failed images use an accessible placeholder and do not affect card interaction.
|
||||||
|
- Source attribution without a URL renders as plain text; valid HTTPS sources remain links.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm vitest run tests/unit/image-prompt-museum-route.test.ts tests/unit/image-prompt-museum-api.test.ts tests/unit/image-prompt-museum-page.test.tsx`: PASS, 3 files / 27 tests, including missing/null source URL projection and no-link rendering.
|
||||||
|
- `pnpm typecheck`: PASS.
|
||||||
|
- Scoped ESLint across the eight owned source/test files: PASS.
|
||||||
|
- `pnpm build:vite`: PASS; existing dynamic-import and chunk-size warnings only.
|
||||||
|
- Targeted Electron E2E `tests/e2e/image-workspace-conversations.spec.ts --grep "keeps Prompt Museum cards usable when relative media fails"`: PASS, 1/1. The local fixture returned a controlled relative thumbnail and invalid media MIME; the failed-image placeholder remained visible, the card stayed enabled, and its detail sheet opened.
|
||||||
|
- `git diff --check`: PASS; Git emitted only existing LF-to-CRLF checkout warnings.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Production validation still requires a real Works account and deployed media endpoint; local automation does not prove production content availability.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: `.project-docs/30-worklog/current-state.md` and Prompt Museum evidence/commitment entries during integration.
|
||||||
|
- Proposal: record that protected relative Prompt Museum media is fetched through a Main-owned bounded authenticated proxy while HTTPS CDN media remains direct.
|
||||||
|
- Evidence: focused 27 tests, typecheck, scoped lint, and Vite/Main/Preload build passed.
|
||||||
|
- Future impact: future Prompt Museum media URL or MIME additions must update the server validator, Main proxy, and Renderer validator together.
|
||||||
|
- Semantic conflicts: none identified; this implements the existing Main-owned Museum boundary.
|
||||||
|
- Human confirmation required: no for integration of this behavior; production deployment/smoke remains an external release decision.
|
||||||
@@ -14,6 +14,16 @@ const MAX_FACET_ITEMS = 256;
|
|||||||
const MAX_CATEGORIES = 32;
|
const MAX_CATEGORIES = 32;
|
||||||
const MAX_IMAGES = 16;
|
const MAX_IMAGES = 16;
|
||||||
const MAX_VARIABLES = 64;
|
const MAX_VARIABLES = 64;
|
||||||
|
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/;
|
||||||
|
const LOCAL_MEDIA_PATH_PATTERN = /^\/api\/works\/image-prompt-museum\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/media\/(thumbnail|[0-9]+)$/;
|
||||||
|
const TRUSTED_MEDIA_MIME_TYPES = new Set([
|
||||||
|
'image/avif',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
type PromptMuseumRouteDependencies = {
|
type PromptMuseumRouteDependencies = {
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
@@ -22,11 +32,17 @@ type PromptMuseumRouteDependencies = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isMuseumPath(pathname: string): boolean {
|
function isMuseumPath(pathname: string): boolean {
|
||||||
return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname);
|
return pathname === LOCAL_ROOT
|
||||||
|
|| /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname)
|
||||||
|
|| LOCAL_MEDIA_PATH_PATTERN.test(pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
function upstreamPath(pathname: string): string | null {
|
function upstreamPath(pathname: string): string | null {
|
||||||
if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT;
|
if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT;
|
||||||
|
const mediaMatch = LOCAL_MEDIA_PATH_PATTERN.exec(pathname);
|
||||||
|
if (mediaMatch) {
|
||||||
|
return `${UPSTREAM_ROOT}/${mediaMatch[1]}/media/${mediaMatch[2]}`;
|
||||||
|
}
|
||||||
const entryId = pathname.slice(`${LOCAL_ROOT}/`.length);
|
const entryId = pathname.slice(`${LOCAL_ROOT}/`.length);
|
||||||
if (!entryId) return null;
|
if (!entryId) return null;
|
||||||
return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`;
|
return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`;
|
||||||
@@ -80,6 +96,12 @@ function httpsUrl(value: unknown): string {
|
|||||||
return parsed.toString();
|
return parsed.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageUrl(value: unknown): string {
|
||||||
|
const raw = boundedString(value, 2048);
|
||||||
|
if (MEDIA_URL_PATTERN.test(raw)) return raw;
|
||||||
|
return httpsUrl(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function nullableHttpsUrl(value: unknown): string | null | undefined {
|
function nullableHttpsUrl(value: unknown): string | null | undefined {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (value === null) return null;
|
if (value === null) return null;
|
||||||
@@ -116,13 +138,43 @@ function boundedArray(value: unknown, maximum: number): unknown[] {
|
|||||||
function projectImage(value: unknown): Record<string, unknown> {
|
function projectImage(value: unknown): Record<string, unknown> {
|
||||||
const image = asRecord(value);
|
const image = asRecord(value);
|
||||||
return {
|
return {
|
||||||
url: httpsUrl(image.url),
|
url: imageUrl(image.url),
|
||||||
width: positiveInteger(image.width, 32_768),
|
width: positiveInteger(image.width, 32_768),
|
||||||
height: positiveInteger(image.height, 32_768),
|
height: positiveInteger(image.height, 32_768),
|
||||||
alt: boundedString(image.alt, 500),
|
alt: boundedString(image.alt, 500),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mediaMimeType(response: Response): string | null {
|
||||||
|
const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
||||||
|
return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedMedia(response: Response): Promise<Buffer | null> {
|
||||||
|
const declaredLength = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) {
|
||||||
|
await response.body?.cancel().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.body) return null;
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > MAX_MEDIA_BYTES) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
if (size === 0) return null;
|
||||||
|
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||||
|
}
|
||||||
|
|
||||||
function projectCategory(value: unknown): Record<string, unknown> {
|
function projectCategory(value: unknown): Record<string, unknown> {
|
||||||
const category = asRecord(value);
|
const category = asRecord(value);
|
||||||
const group = boundedString(category.group, 32);
|
const group = boundedString(category.group, 32);
|
||||||
@@ -142,6 +194,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
|
|||||||
const source = asRecord(attribution.source);
|
const source = asRecord(attribution.source);
|
||||||
const license = asRecord(attribution.license);
|
const license = asRecord(attribution.license);
|
||||||
const authorUrl = nullableHttpsUrl(author.url);
|
const authorUrl = nullableHttpsUrl(author.url);
|
||||||
|
const sourceUrl = nullableHttpsUrl(source.url);
|
||||||
const licenseUrl = nullableHttpsUrl(license.url);
|
const licenseUrl = nullableHttpsUrl(license.url);
|
||||||
return {
|
return {
|
||||||
author: {
|
author: {
|
||||||
@@ -150,7 +203,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
|
|||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
name: boundedString(source.name, 300),
|
name: boundedString(source.name, 300),
|
||||||
url: httpsUrl(source.url),
|
...(sourceUrl === undefined ? {} : { url: sourceUrl }),
|
||||||
},
|
},
|
||||||
license: {
|
license: {
|
||||||
name: boundedString(license.name, 300),
|
name: boundedString(license.name, 300),
|
||||||
@@ -310,6 +363,7 @@ export function createImagePromptMuseumRouteHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname);
|
||||||
const token = await getAccessToken({ fetchImpl });
|
const token = await getAccessToken({ fetchImpl });
|
||||||
if (!token) {
|
if (!token) {
|
||||||
sendJson(res, 401, {
|
sendJson(res, 401, {
|
||||||
@@ -326,7 +380,7 @@ export function createImagePromptMuseumRouteHandler(
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
@@ -344,12 +398,30 @@ export function createImagePromptMuseumRouteHandler(
|
|||||||
response = await request(refreshed);
|
response = await request(refreshed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readPayload(response);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel().catch(() => undefined);
|
||||||
sendSafeError(res, response.status);
|
sendSafeError(res, response.status);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMediaRequest) {
|
||||||
|
const mimeType = mediaMimeType(response);
|
||||||
|
if (!mimeType) {
|
||||||
|
await response.body?.cancel().catch(() => undefined);
|
||||||
|
sendInvalidResponse(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const bytes = await readBoundedMedia(response);
|
||||||
|
if (!bytes) {
|
||||||
|
sendInvalidResponse(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readPayload(response);
|
||||||
|
|
||||||
if (payload === null) {
|
if (payload === null) {
|
||||||
sendInvalidResponse(res);
|
sendInvalidResponse(res);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export type PromptMuseumAuthor = {
|
|||||||
|
|
||||||
export type PromptMuseumSource = {
|
export type PromptMuseumSource = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PromptMuseumLicense = {
|
export type PromptMuseumLicense = {
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ type PromptMuseumEnvelope<T> = {
|
|||||||
data?: T;
|
data?: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PromptMuseumMedia = {
|
||||||
|
dataBase64: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROMPT_MUSEUM_MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/;
|
||||||
|
const PROMPT_MUSEUM_MEDIA_MIME_TYPES = new Set([
|
||||||
|
'image/avif',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
const MAX_MEDIA_BASE64_LENGTH = Math.ceil((10 * 1024 * 1024) / 3) * 4;
|
||||||
|
|
||||||
export class PromptMuseumApiError extends Error {
|
export class PromptMuseumApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
@@ -92,3 +107,23 @@ export async function fetchPromptMuseumPage(query: PromptMuseumListQuery = {}):
|
|||||||
export async function fetchPromptMuseumEntry(entryId: string): Promise<PromptMuseumEntry> {
|
export async function fetchPromptMuseumEntry(entryId: string): Promise<PromptMuseumEntry> {
|
||||||
return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`);
|
return await requestData(`${IMAGE_PROMPT_MUSEUM_API_PATH}/${encodeURIComponent(entryId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPromptMuseumMedia(mediaUrl: string): Promise<string> {
|
||||||
|
if (!PROMPT_MUSEUM_MEDIA_URL_PATTERN.test(mediaUrl)) {
|
||||||
|
throw new PromptMuseumApiError(400, 'PROMPT_MUSEUM_INVALID_MEDIA_URL', '提示词图片地址无效');
|
||||||
|
}
|
||||||
|
const localPath = mediaUrl.replace('/api/image-prompt-museum/', `${IMAGE_PROMPT_MUSEUM_API_PATH}/`);
|
||||||
|
const payload = await hostApiFetch<PromptMuseumMedia>(localPath);
|
||||||
|
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
|
||||||
|
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
|
||||||
|
if (
|
||||||
|
!PROMPT_MUSEUM_MEDIA_MIME_TYPES.has(mimeType)
|
||||||
|
|| !dataBase64
|
||||||
|
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|
||||||
|
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|
||||||
|
|| dataBase64.length % 4 === 1
|
||||||
|
) {
|
||||||
|
throw new PromptMuseumApiError(502, 'PROMPT_MUSEUM_INVALID_MEDIA_RESPONSE', '提示词图片返回了无效数据');
|
||||||
|
}
|
||||||
|
return `data:${mimeType};base64,${dataBase64}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import {
|
import {
|
||||||
fetchPromptMuseumEntry,
|
fetchPromptMuseumEntry,
|
||||||
|
fetchPromptMuseumMedia,
|
||||||
fetchPromptMuseumPage,
|
fetchPromptMuseumPage,
|
||||||
PromptMuseumApiError,
|
PromptMuseumApiError,
|
||||||
} from '@/lib/image-prompt-museum';
|
} from '@/lib/image-prompt-museum';
|
||||||
@@ -84,6 +85,17 @@ function errorMessage(error: unknown): string {
|
|||||||
return '获取灵感暂时不可用,请稍后再试';
|
return '获取灵感暂时不可用,请稍后再试';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function directHttpsUrl(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === 'https:' && !parsed.username && !parsed.password
|
||||||
|
? parsed.toString()
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function MuseumImage({
|
function MuseumImage({
|
||||||
image,
|
image,
|
||||||
className,
|
className,
|
||||||
@@ -93,22 +105,52 @@ function MuseumImage({
|
|||||||
className?: string;
|
className?: string;
|
||||||
sizes?: string;
|
sizes?: string;
|
||||||
}) {
|
}) {
|
||||||
const [failed, setFailed] = useState(false);
|
const directSource = directHttpsUrl(image.url);
|
||||||
if (failed || !image.url) {
|
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||||
|
const [mediaState, setMediaState] = useState<{
|
||||||
|
imageUrl: string;
|
||||||
|
source: string | null;
|
||||||
|
failed: boolean;
|
||||||
|
}>({ imageUrl: '', source: null, failed: false });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (directHttpsUrl(image.url)) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void fetchPromptMuseumMedia(image.url).then(
|
||||||
|
(dataUrl) => {
|
||||||
|
if (!cancelled) setMediaState({ imageUrl: image.url, source: dataUrl, failed: false });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setMediaState({ imageUrl: image.url, source: null, failed: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [image.url]);
|
||||||
|
|
||||||
|
const relativeState = mediaState.imageUrl === image.url ? mediaState : null;
|
||||||
|
const source = directSource ?? relativeState?.source ?? null;
|
||||||
|
const failed = failedUrl === image.url || relativeState?.failed === true;
|
||||||
|
|
||||||
|
if (failed || !source) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
<div
|
||||||
|
aria-label={failed ? `${image.alt}加载失败` : `${image.alt}正在加载`}
|
||||||
|
className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}
|
||||||
|
>
|
||||||
<ImageIcon className="h-8 w-8" aria-hidden="true" />
|
<ImageIcon className="h-8 w-8" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={image.url}
|
src={source}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={className}
|
className={className}
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailedUrl(image.url)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,6 +269,7 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<dt className="w-12 shrink-0 text-muted-foreground">来源</dt>
|
<dt className="w-12 shrink-0 text-muted-foreground">来源</dt>
|
||||||
<dd className="min-w-0 truncate font-medium text-foreground">
|
<dd className="min-w-0 truncate font-medium text-foreground">
|
||||||
|
{entry.attribution.source.url ? (
|
||||||
<a
|
<a
|
||||||
href={entry.attribution.source.url}
|
href={entry.attribution.source.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -236,6 +279,7 @@ function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
|
|||||||
<span className="truncate">{entry.attribution.source.name}</span>
|
<span className="truncate">{entry.attribution.source.name}</span>
|
||||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
|
) : entry.attribution.source.name}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,94 @@
|
|||||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||||
|
|
||||||
test.describe('AI Design workspace', () => {
|
test.describe('AI Design workspace', () => {
|
||||||
|
test('keeps Prompt Museum cards usable when relative media fails', async ({ launchElectronApp }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const app = await launchElectronApp({
|
||||||
|
imageWorkspaceMode: 'local',
|
||||||
|
skipSetup: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await getStableWindow(app);
|
||||||
|
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||||
|
await page.getByTestId('ai-module-option-painting').click();
|
||||||
|
const imageSidebar = page.getByTestId('sidebar-image-workspace');
|
||||||
|
await expect(imageSidebar.getByTestId('sidebar-open-image-prompt-museum')).toBeVisible();
|
||||||
|
|
||||||
|
await app.evaluate(({ ipcMain }) => {
|
||||||
|
const image = {
|
||||||
|
url: '/api/image-prompt-museum/e2e-entry/media/thumbnail',
|
||||||
|
width: 1200,
|
||||||
|
height: 900,
|
||||||
|
alt: 'E2E museum thumbnail',
|
||||||
|
};
|
||||||
|
const card = {
|
||||||
|
id: 'e2e-entry',
|
||||||
|
slug: 'e2e-entry',
|
||||||
|
title: '相对媒体 E2E',
|
||||||
|
summary: '验证图片失败不会阻断卡片。',
|
||||||
|
thumbnail: image,
|
||||||
|
categories: [],
|
||||||
|
model: { id: 'e2e-model', name: 'E2E Model' },
|
||||||
|
language: 'zh-CN',
|
||||||
|
attribution: {
|
||||||
|
author: { name: 'E2E 作者' },
|
||||||
|
source: { name: 'E2E 来源' },
|
||||||
|
license: { name: '测试许可', attributionText: 'E2E attribution' },
|
||||||
|
},
|
||||||
|
publishedAt: '2026-08-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-18T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const respond = (json: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
data: { status: 200, ok: true, json },
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.removeHandler('hostapi:fetch');
|
||||||
|
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
|
||||||
|
const path = request.path ?? '';
|
||||||
|
if (path === '/api/works/image-prompt-museum') {
|
||||||
|
return respond({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [card],
|
||||||
|
facets: { useCases: [], styles: [], subjects: [] },
|
||||||
|
nextCursor: null,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === '/api/works/image-prompt-museum/e2e-entry') {
|
||||||
|
return respond({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...card,
|
||||||
|
prompt: 'Create a resilient image card.',
|
||||||
|
variables: [],
|
||||||
|
images: [image],
|
||||||
|
requiresReferenceImages: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === '/api/works/image-prompt-museum/e2e-entry/media/thumbnail') {
|
||||||
|
return respond({ mimeType: 'text/html', dataBase64: 'PGgxPnVuc2FmZTwvaDE+' });
|
||||||
|
}
|
||||||
|
return { ok: false, error: { message: `Unexpected E2E Host API request: ${path}` } };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await imageSidebar.getByTestId('sidebar-open-image-prompt-museum').click();
|
||||||
|
const cardButton = page.getByRole('button', { name: '查看 相对媒体 E2E' });
|
||||||
|
await expect(cardButton).toBeVisible();
|
||||||
|
await expect(page.getByLabel('E2E museum thumbnail加载失败')).toBeVisible();
|
||||||
|
await expect(cardButton).toBeEnabled();
|
||||||
|
await cardButton.click();
|
||||||
|
await expect(page.getByRole('button', { name: '关闭详情' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await closeElectronApp(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps the conversation and generation task panes full height', async ({ launchElectronApp }) => {
|
test('keeps the conversation and generation task panes full height', async ({ launchElectronApp }) => {
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
const app = await launchElectronApp({
|
const app = await launchElectronApp({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import {
|
import {
|
||||||
fetchPromptMuseumEntry,
|
fetchPromptMuseumEntry,
|
||||||
|
fetchPromptMuseumMedia,
|
||||||
fetchPromptMuseumPage,
|
fetchPromptMuseumPage,
|
||||||
PromptMuseumApiError,
|
PromptMuseumApiError,
|
||||||
} from '@/lib/image-prompt-museum';
|
} 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 fetchPromptMuseumPageMock = vi.hoisted(() => vi.fn());
|
||||||
const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn());
|
const fetchPromptMuseumEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const fetchPromptMuseumMediaMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/image-prompt-museum')>();
|
const actual = await importOriginal<typeof import('@/lib/image-prompt-museum')>();
|
||||||
@@ -14,6 +15,7 @@ vi.mock('@/lib/image-prompt-museum', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args),
|
fetchPromptMuseumPage: (...args: unknown[]) => fetchPromptMuseumPageMock(...args),
|
||||||
fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args),
|
fetchPromptMuseumEntry: (...args: unknown[]) => fetchPromptMuseumEntryMock(...args),
|
||||||
|
fetchPromptMuseumMedia: (...args: unknown[]) => fetchPromptMuseumMediaMock(...args),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ describe('Prompt Museum page', () => {
|
|||||||
useImagePromptMuseumStore.getState().clearPendingPrompt();
|
useImagePromptMuseumStore.getState().clearPendingPrompt();
|
||||||
fetchPromptMuseumPageMock.mockResolvedValue(pageFixture);
|
fetchPromptMuseumPageMock.mockResolvedValue(pageFixture);
|
||||||
fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture);
|
fetchPromptMuseumEntryMock.mockResolvedValue(entryFixture);
|
||||||
|
fetchPromptMuseumMediaMock.mockResolvedValue('data:image/webp;base64,AQID');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the inspiration filters compact and below the native title bar', async () => {
|
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' }),
|
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 type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum';
|
import { createImagePromptMuseumRouteHandler } from '@electron/api/routes/image-prompt-museum';
|
||||||
|
import type { PromptMuseumCard } from '../../shared/image-prompt-museum';
|
||||||
|
|
||||||
function createResponse() {
|
function createResponse() {
|
||||||
const chunks: string[] = [];
|
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', () => {
|
describe('Prompt Museum Main route boundary', () => {
|
||||||
const fetchImpl = vi.fn<typeof fetch>();
|
const fetchImpl = vi.fn<typeof fetch>();
|
||||||
const getAccessToken = vi.fn();
|
const getAccessToken = vi.fn();
|
||||||
@@ -74,6 +95,165 @@ describe('Prompt Museum Main route boundary', () => {
|
|||||||
expect(response.json).toMatchObject({ success: true });
|
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 () => {
|
it('returns a stable auth error without calling the upstream service', async () => {
|
||||||
getAccessToken.mockResolvedValue(null);
|
getAccessToken.mockResolvedValue(null);
|
||||||
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
const handler = createImagePromptMuseumRouteHandler({ fetchImpl, getAccessToken });
|
||||||
@@ -215,23 +395,7 @@ describe('Prompt Museum Main route boundary', () => {
|
|||||||
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: [{
|
items: [card('http://internal.example/secret.png')],
|
||||||
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',
|
|
||||||
}],
|
|
||||||
facets: { useCases: [], styles: [], subjects: [] },
|
facets: { useCases: [], styles: [], subjects: [] },
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user