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({ 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'); }); });