203 lines
6.6 KiB
TypeScript
203 lines
6.6 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 {
|
|
createImageWorkspaceProject,
|
|
deleteImageWorkspaceProject,
|
|
fetchImageWorkspace,
|
|
ImageWorkspaceApiError,
|
|
openImageWorkspaceEvents,
|
|
resolveImageWorkspaceAssetUrl,
|
|
saveImageWorkspaceAsset,
|
|
submitImageWorkspaceCommand,
|
|
uploadImageWorkspaceAsset,
|
|
} from '@/lib/image-workspace';
|
|
import { designBootstrapFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2';
|
|
|
|
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);
|
|
|
|
describe('AI design V2 Renderer API boundary', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
hostApiFetchMock.mockResolvedValue({
|
|
success: true,
|
|
status: 200,
|
|
data: designBootstrapFixture(),
|
|
});
|
|
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
|
createHostEventSourceMock.mockReturnValue({ close: vi.fn() } as unknown as EventSource);
|
|
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
|
});
|
|
|
|
it('uses only the Main-owned Host route', async () => {
|
|
await fetchImageWorkspace();
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
|
});
|
|
|
|
it('creates and deletes one canonical Workspace without Conversation routes', async () => {
|
|
hostApiFetchMock
|
|
.mockResolvedValueOnce({ success: true, status: 200, data: designWorkspaceFixture() })
|
|
.mockResolvedValueOnce({
|
|
success: true,
|
|
status: 200,
|
|
data: { workspaceId: 'workspace/one', deleted: true },
|
|
});
|
|
|
|
await createImageWorkspaceProject(' 角色设计 ', 'client-workspace-1');
|
|
await deleteImageWorkspaceProject('workspace/one');
|
|
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/works/image-workspace/workspaces',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
clientWorkspaceId: 'client-workspace-1',
|
|
title: '角色设计',
|
|
}),
|
|
},
|
|
);
|
|
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone',
|
|
{ method: 'DELETE' },
|
|
);
|
|
});
|
|
|
|
it('submits a discriminated V2 command without duplicating Workspace identity in the body', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
status: 200,
|
|
data: {
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-1',
|
|
workspace: designWorkspaceFixture(),
|
|
},
|
|
});
|
|
|
|
await submitImageWorkspaceCommand({
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace/one',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-1',
|
|
input: {
|
|
kind: 'direct_edit',
|
|
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
|
},
|
|
});
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone/commands',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
kind: 'apply_input',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-1',
|
|
input: {
|
|
kind: 'direct_edit',
|
|
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
it('opens resumable Workspace events after the Host token is ready', async () => {
|
|
const source = await openImageWorkspaceEvents('workspace/one', 'session/one', 'session/one:8');
|
|
|
|
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
|
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone/events?sessionId=session%2Fone&afterEventId=session%2Fone%3A8',
|
|
);
|
|
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
|
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('uploads only supported bounded images through Main', async () => {
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
status: 200,
|
|
data: { assetId: 'asset-1' },
|
|
});
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'reference.png', { type: 'image/png' });
|
|
|
|
await uploadImageWorkspaceAsset('workspace-1', file);
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace-1/assets',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
fileName: 'reference.png',
|
|
mimeType: 'image/png',
|
|
dataBase64: 'AQID',
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
it('resolves and saves assets without exposing the Works Square token', async () => {
|
|
const asset = {
|
|
assetId: 'asset/one',
|
|
workspaceId: 'workspace/one',
|
|
role: 'generated' as const,
|
|
mediaType: 'image' as const,
|
|
mimeType: 'image/png',
|
|
width: 1024,
|
|
height: 1024,
|
|
durationMilliseconds: null,
|
|
generationTaskId: 'task-1',
|
|
createdAt: '2026-08-30T10:00:00Z',
|
|
contentPath: '/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/content',
|
|
};
|
|
hostApiFetchMock.mockResolvedValueOnce({
|
|
success: true,
|
|
status: 200,
|
|
data: { status: 'saved' },
|
|
});
|
|
|
|
await expect(resolveImageWorkspaceAssetUrl(asset.contentPath)).resolves.toBe(
|
|
'http://127.0.0.1:13210/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/content?token=host-token',
|
|
);
|
|
await saveImageWorkspaceAsset(asset);
|
|
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
|
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
|
|
it('normalizes Host failures into a stable API error', async () => {
|
|
hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, {
|
|
status: 409,
|
|
backendCode: 'design_direction_revision_conflict',
|
|
commandOutcome: 'definitive_failure',
|
|
}));
|
|
|
|
await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({
|
|
name: 'ImageWorkspaceApiError',
|
|
status: 409,
|
|
code: 'design_direction_revision_conflict',
|
|
commandOutcome: 'definitive_failure',
|
|
} satisfies Partial<ImageWorkspaceApiError>));
|
|
});
|
|
});
|