需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
215 lines
7.2 KiB
TypeScript
215 lines
7.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { AppError } from '@/lib/error-model';
|
|
import {
|
|
createHostEventSource,
|
|
ensureHostApiToken,
|
|
getHostApiBase,
|
|
hostApiFetch,
|
|
} from '@/lib/host-api';
|
|
import {
|
|
confirmImageWorkspaceGeneration,
|
|
createImageWorkspaceProject,
|
|
fetchImageWorkspace,
|
|
ImageWorkspaceApiError,
|
|
openImageWorkspaceTaskEvents,
|
|
resolveImageWorkspaceAssetUrl,
|
|
saveImageWorkspaceAsset,
|
|
sendImageWorkspaceMessage,
|
|
uploadImageWorkspaceAsset,
|
|
} from '@/lib/image-workspace';
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: vi.fn(),
|
|
createHostEventSource: vi.fn(),
|
|
ensureHostApiToken: vi.fn(),
|
|
getHostApiBase: vi.fn(),
|
|
}));
|
|
|
|
const hostApiFetchMock = vi.mocked(hostApiFetch);
|
|
const createHostEventSourceMock = vi.mocked(createHostEventSource);
|
|
const ensureHostApiTokenMock = vi.mocked(ensureHostApiToken);
|
|
const getHostApiBaseMock = vi.mocked(getHostApiBase);
|
|
|
|
const bootstrap = {
|
|
capabilities: {
|
|
conversation: true,
|
|
generation: true,
|
|
image: true,
|
|
video: false,
|
|
},
|
|
workspaces: [],
|
|
};
|
|
|
|
describe('AI design renderer API boundary', () => {
|
|
beforeEach(() => {
|
|
hostApiFetchMock.mockReset();
|
|
hostApiFetchMock.mockResolvedValue({ success: true, status: 200, data: bootstrap });
|
|
ensureHostApiTokenMock.mockReset();
|
|
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
|
createHostEventSourceMock.mockReset();
|
|
createHostEventSourceMock.mockReturnValue({ close: vi.fn() } as unknown as EventSource);
|
|
getHostApiBaseMock.mockReset();
|
|
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
|
});
|
|
|
|
it('uses only the Main-owned route and never forwards a Works Square token', async () => {
|
|
await fetchImageWorkspace();
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
|
});
|
|
|
|
it('opens task events only after the local Host API token is ready', async () => {
|
|
const source = await openImageWorkspaceTaskEvents('workspace/one');
|
|
|
|
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
|
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
|
);
|
|
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
|
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('creates an idempotent Workspace with a trimmed user-visible title', async () => {
|
|
await createImageWorkspaceProject(' 角色设计 ', 'workspace-client-1');
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/workspaces', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
clientWorkspaceId: 'workspace-client-1',
|
|
title: '角色设计',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
|
await sendImageWorkspaceMessage(
|
|
'workspace/one',
|
|
4,
|
|
' 继续调整构图 ',
|
|
'turn-client-1',
|
|
);
|
|
|
|
const [path, init] = hostApiFetchMock.mock.calls[0];
|
|
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
|
|
expect(init).toMatchObject({ method: 'POST' });
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
expectedTurnRevision: 4,
|
|
message: '继续调整构图',
|
|
attachmentAssetIds: [],
|
|
clientTurnId: 'turn-client-1',
|
|
});
|
|
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
|
});
|
|
|
|
it('sends the selected first-frame Asset id with the conversation turn', async () => {
|
|
await sendImageWorkspaceMessage(
|
|
'workspace-one',
|
|
5,
|
|
'使用作品图片作为视频首帧',
|
|
'turn-first-frame',
|
|
['asset-first-frame'],
|
|
);
|
|
|
|
const [, init] = hostApiFetchMock.mock.calls[0];
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
clientTurnId: 'turn-first-frame',
|
|
expectedTurnRevision: 5,
|
|
attachmentAssetIds: ['asset-first-frame'],
|
|
});
|
|
});
|
|
|
|
it('uploads a supported local image through the authenticated Main boundary', async () => {
|
|
const asset = {
|
|
assetId: 'asset-uploaded',
|
|
workspaceId: 'workspace-one',
|
|
mediaType: 'image' as const,
|
|
mimeType: 'image/png',
|
|
width: 1200,
|
|
height: 800,
|
|
durationMilliseconds: null,
|
|
createdAt: '2026-08-06T10:00:00Z',
|
|
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content',
|
|
};
|
|
hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: asset });
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'poster.webp', {
|
|
type: 'image/webp',
|
|
});
|
|
|
|
await expect(uploadImageWorkspaceAsset('workspace-one', file)).resolves.toEqual(asset);
|
|
|
|
const [path, init] = hostApiFetchMock.mock.calls[0];
|
|
expect(path).toBe('/api/works/image-workspace/workspaces/workspace-one/assets');
|
|
expect(init).toMatchObject({ method: 'POST' });
|
|
expect(JSON.parse(String(init?.body))).toEqual({
|
|
fileName: 'poster.webp',
|
|
mimeType: 'image/webp',
|
|
dataBase64: 'AQID',
|
|
});
|
|
});
|
|
|
|
it('confirms a Quote through a separate structured action route', async () => {
|
|
await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one');
|
|
|
|
const [path, init] = hostApiFetchMock.mock.calls[0];
|
|
expect(path).toBe(
|
|
'/api/works/image-workspace/workspaces/workspace-one/quotes/quote%2Fone/confirm',
|
|
);
|
|
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
expectedTurnRevision: 5,
|
|
clientTurnId: expect.stringMatching(/^turn-/),
|
|
});
|
|
});
|
|
|
|
it('preserves structured Main error codes for revision recovery', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new AppError(
|
|
'UNKNOWN',
|
|
'Design workspace revision has changed',
|
|
undefined,
|
|
{ status: 409, backendCode: 'workspace_revision_conflict' },
|
|
));
|
|
|
|
await expect(fetchImageWorkspace()).rejects.toMatchObject<ImageWorkspaceApiError>({
|
|
status: 409,
|
|
code: 'workspace_revision_conflict',
|
|
});
|
|
});
|
|
|
|
it('builds a loopback media URL with only the Host API session token', async () => {
|
|
await expect(resolveImageWorkspaceAssetUrl('/api/works/image-workspace/assets/one'))
|
|
.resolves.toBe(
|
|
'http://127.0.0.1:13210/api/works/image-workspace/assets/one?token=host-token',
|
|
);
|
|
});
|
|
|
|
it('asks Main to save a private image asset without exposing its cloud URL', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
status: 200,
|
|
data: { status: 'saved' },
|
|
});
|
|
|
|
await expect(saveImageWorkspaceAsset({
|
|
assetId: 'asset/one',
|
|
workspaceId: 'workspace/one',
|
|
mediaType: 'image',
|
|
mimeType: 'image/png',
|
|
width: 1024,
|
|
height: 1024,
|
|
durationMilliseconds: null,
|
|
createdAt: '2026-08-05T12:00:00Z',
|
|
contentPath: '/api/works/image-workspace/private/content',
|
|
})).resolves.toEqual({ status: 'saved' });
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
defaultFileName: 'Makelore-AI-Design-asset-one.png',
|
|
}),
|
|
},
|
|
);
|
|
expect(JSON.stringify(hostApiFetchMock.mock.calls[0])).not.toContain('private/content');
|
|
});
|
|
});
|