Files
makelore/tests/unit/image-workspace-api.test.ts
brother7 03dae62cf3 feat: 支持 AI 设计项目多会话交互
需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。

实现:
- 增加会话选择、新建入口及本地数据迁移
- Conversation 独立消息、Brief、Quote 与流式状态
- 保留项目任务并修复 Task revision 与 ABA 异步竞态
- 保持 Enter 发送、Shift+Enter 换行及 IME 保护
2026-08-06 18:22:15 +08:00

217 lines
7.4 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', 'conversation/one');
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
expect(createHostEventSourceMock).toHaveBeenCalledWith(
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%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',
'conversation/one',
4,
' 继续调整构图 ',
'turn-client-1',
);
const [path, init] = hostApiFetchMock.mock.calls[0];
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%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',
'conversation-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', 'conversation-one', 5, 'quote/one');
const [path, init] = hostApiFetchMock.mock.calls[0];
expect(path).toBe(
'/api/works/image-workspace/workspaces/workspace-one/conversations/conversation-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');
});
});