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', })); await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({ name: 'ImageWorkspaceApiError', status: 409, code: 'design_direction_revision_conflict', } satisfies Partial)); }); });