Files
makelore/tests/unit/cloud-attachment-upload.test.ts
T

290 lines
21 KiB
TypeScript

import { createElement, useState } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CloudAgentsModule } from '@electron/services/cloud-agents';
import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session';
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
import { CloudComposerAttachments } from '@/pages/CloudAgents/CloudComposerAttachments';
import { useAuthStore } from '@/stores/auth';
const host = vi.hoisted(() => ({ call: vi.fn() }));
vi.mock('@/lib/host-api', () => ({ hostApiFetch: host.call, createHostEventSource: vi.fn(), ensureHostApiToken: vi.fn() }));
const modules: CloudAgentsModule[] = [];
const login = (key = 'a'.repeat(64)) => storeWorksSquareSession({
accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key,
});
const json = (body: unknown) => new Response(JSON.stringify(body));
const session = () => json({ access_token: 'agent-secret', token_type: 'bearer', scope: 'makelore-agents',
expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test' });
const uploaded = { object_name: 'temporary/notes.txt', file_name: 'notes.txt', file_type: 'text/plain', parse_supported: false, parse_methods: [] };
const bytesInput = { bytesBase64: 'aGVsbG8=', fileName: 'notes.txt', mimeType: 'text/plain' };
const createModule = (transport: (url: string, init?: RequestInit) => Promise<Response>) => {
const module = new CloudAgentsModule(transport); modules.push(module); return module;
};
const readBlob = (blob: Blob) => new Promise<string>((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = reject;
reader.readAsDataURL(blob);
});
const attachment = (id: string, name: string) => ({ file_id: id, file_name: name, file_size: 5,
path: '/attachments/' + id, original_path: '/original/' + id });
function browserFiles({ scopeKey = 'same-thread', disabled = false, activeView = true, onBusy = vi.fn(), onSelected = vi.fn(), ensureThread = vi.fn(async () => 'thread-1') } = {}) {
function Harness(props: { scopeKey: string; disabled: boolean; activeView: boolean; onBusy: (busy: boolean) => void }) {
const [selected, setSelected] = useState(['prior']);
return createElement(CloudComposerAttachments, { ...props, selected, ensureThread,
onSelected: ids => { onSelected(ids); setSelected(ids); }, actions: createElement('button', { type: 'button', 'aria-label': '语音输入' }) },
createElement('textarea', { 'aria-label': '消息', defaultValue: '保留文字' }));
}
const view = render(createElement(Harness, { scopeKey, disabled, activeView, onBusy }));
return { ...view, onBusy, onSelected, ensureThread, update: (next: { scopeKey: string; disabled?: boolean; activeView?: boolean; onBusy?: (busy: boolean) => void }) =>
view.rerender(createElement(Harness, { scopeKey: next.scopeKey, disabled: next.disabled ?? disabled, activeView: next.activeView ?? activeView, onBusy: next.onBusy ?? onBusy })) };
}
function mockBrowserFiles() {
const upload = vi.spyOn(cloudAgentsApi, 'uploadFile').mockImplementation(async file => ({ ...uploaded, object_name: 'temporary/' + file.name, file_name: file.name, file_type: file.type }));
const call = vi.spyOn(cloudAgentsApi, 'call').mockImplementation(async operation => {
if (operation === 'confirmAttachment') return { attachments: [attachment('image-1', 'photo.png'), attachment('notes-1', 'notes.txt')] } as never;
return operation === 'attachments' ? { attachments: [] } as never : { files: [] } as never;
});
return { upload, call };
}
beforeEach(() => { vi.restoreAllMocks(); host.call.mockReset(); useAuthStore.setState({ user: null }); clearWorksSquareSession(); login(); });
afterEach(() => { cleanup(); modules.splice(0).forEach(module => module.dispose()); useAuthStore.setState({ user: null }); clearWorksSquareSession(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe('Main attachment bytes upload', () => {
it('uploads only decoded file bytes using Main-owned credentials and projects the temporary upload', async () => {
const transport = vi.fn(async (url: string, _init?: RequestInit) => url.endsWith('/session') ? session() : json({ ...uploaded, access_token: 'never-project' }));
expect(await createModule(transport).uploadBytes(bytesInput)).toEqual(uploaded);
const [url, init] = transport.mock.calls[1];
expect(url).toBe('https://agents.example.test/api/makelore/attachments/tmp');
expect(init?.method).toBe('POST'); expect(init?.redirect).toBe('error');
expect(init?.headers).toEqual({ Authorization: 'Bearer agent-secret' });
expect(init?.body).toBeInstanceOf(FormData);
const file = (init!.body as FormData).get('file') as File;
expect(file.name).toBe('notes.txt'); expect(file.type).toBe('text/plain'); expect(file.size).toBe(5);
expect(await readBlob(file)).toBe('data:text/plain;base64,aGVsbG8=');
});
it.each([
{ ...bytesInput, bytesBase64: '' }, { ...bytesInput, bytesBase64: 'aGVsbG8' },
{ ...bytesInput, bytesBase64: 'aGVs bG8=' }, { ...bytesInput, bytesBase64: 'aGVsbG8=\n' },
{ ...bytesInput, bytesBase64: 'Zh==' }, { ...bytesInput, bytesBase64: 'data:text/plain;base64,aGVsbG8=' },
{ ...bytesInput, bytesBase64: 'aG=VsbG8' }, { ...bytesInput, fileName: '../secret.txt' },
{ ...bytesInput, fileName: 'C:\\secret.txt' }, { ...bytesInput, fileName: 'bad\u0000name' },
{ ...bytesInput, fileName: '..' }, { ...bytesInput, fileName: ' ' },
{ ...bytesInput, mimeType: 'text/plain\r\nX: secret' }, { ...bytesInput, path: '/tmp/private' },
])('rejects malformed bytes, names and metadata before network access (%j)', async input => {
const transport = vi.fn();
await expect(createModule(transport).uploadBytes(input)).rejects.toMatchObject({ status: 422, code: 'attachment_invalid' });
expect(transport).not.toHaveBeenCalled();
});
it('accepts exactly 5 MiB and rejects decoded or encoded bytes exceeding it', async () => {
const transport = vi.fn(async (url: string) => url.endsWith('/session') ? session() : json(uploaded));
const module = createModule(transport);
await expect(module.uploadBytes({ ...bytesInput, bytesBase64: Buffer.alloc(5 * 1024 * 1024).toString('base64') })).resolves.toEqual(uploaded);
expect((transport.mock.calls[1] as [string, RequestInit])[1].body).toBeInstanceOf(FormData);
transport.mockClear();
for (const extra of [1, 3]) {
await expect(module.uploadBytes({ ...bytesInput, bytesBase64: Buffer.alloc(5 * 1024 * 1024 + extra).toString('base64') }))
.rejects.toMatchObject({ status: 422, code: 'attachment_too_large' });
}
expect(transport).not.toHaveBeenCalled();
});
it('requires a logged-in account before uploading', async () => {
clearWorksSquareSession(); const transport = vi.fn();
await expect(createModule(transport).uploadBytes(bytesInput)).rejects.toMatchObject({ status: 401 });
expect(transport).not.toHaveBeenCalled();
});
it('rejects late upload results after the account changes', async () => {
let finish!: (response: Response) => void;
const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise<Response>(resolve => { finish = resolve; }));
const request = createModule(transport).uploadBytes(bytesInput);
const rejection = expect(request).rejects.toMatchObject({ status: 409, code: 'account_changed' });
await waitFor(() => expect(transport).toHaveBeenCalledTimes(2));
login('b'.repeat(64)); finish(json(uploaded)); await rejection;
});
});
describe('Renderer attachment input', () => {
it('sends browser file bytes through the typed local Host API without any disk path', async () => {
host.call.mockResolvedValue(uploaded);
const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
Object.defineProperty(file, 'arrayBuffer', { value: async () => new TextEncoder().encode('hello').buffer });
expect(await cloudAgentsApi.uploadFile(file)).toEqual(uploaded);
expect(host.call).toHaveBeenCalledWith('/api/cloud-agents/attachments/upload', {
method: 'POST', body: JSON.stringify(bytesInput),
});
});
it('never dispatches converted bytes after the originating operation becomes inactive', async () => {
let finish!: (bytes: ArrayBuffer) => void;
const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
Object.defineProperty(file, 'arrayBuffer', { value: () => new Promise<ArrayBuffer>(resolve => { finish = resolve; }) });
let active = true;
const request = cloudAgentsApi.uploadFile(file, () => active);
const rejection = expect(request).rejects.toThrow('对话已切换');
active = false; finish(new TextEncoder().encode('hello').buffer); await rejection;
expect(host.call).not.toHaveBeenCalled();
});
it('does not begin reading file bytes for an already inactive operation', async () => {
const file = new File(['hello'], 'notes.txt', { type: 'text/plain' });
const readBytes = vi.fn(); Object.defineProperty(file, 'arrayBuffer', { value: readBytes });
await expect(cloudAgentsApi.uploadFile(file, () => false)).rejects.toThrow('对话已切换');
expect(readBytes).not.toHaveBeenCalled(); expect(host.call).not.toHaveBeenCalled();
});
});
describe('Current trial composer browser attachments', () => {
it('adds multiple files from the input, preserves the draft and native menu, and previews only confirmed browser images', async () => {
const createPreview = vi.fn(() => 'blob:local-image'); const revokePreview = vi.fn();
vi.stubGlobal('URL', class extends URL { static createObjectURL = createPreview; static revokeObjectURL = revokePreview; });
const { upload, call } = mockBrowserFiles(); const view = browserFiles();
const files = [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })];
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } });
await screen.findByRole('img', { name: 'photo.png' });
expect(upload).toHaveBeenCalledTimes(2); expect(view.ensureThread).toHaveBeenCalledTimes(1);
expect(call).toHaveBeenCalledWith('confirmAttachment', { thread_id: 'thread-1', attachments: [
{ object_name: 'temporary/photo.png', file_type: 'image/png' }, { object_name: 'temporary/notes.txt', file_type: 'text/plain' },
] });
expect(view.onSelected).toHaveBeenCalledWith(['prior', 'image-1', 'notes-1']);
expect(screen.getByLabelText('消息')).toHaveValue('保留文字');
expect(screen.getByRole('button', { name: '语音输入' }).closest('.agent-attachment-actions'))
.toContainElement(screen.getByRole('button', { name: '添加文件或图片' }));
expect(call.mock.calls.some(([name]) => ['submit', 'preview', 'parseAttachment'].includes(name))).toBe(false);
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
fireEvent.keyDown(screen.getByRole('button', { name: '添加文件或图片' }), { key: 'ArrowDown' });
expect(await screen.findByRole('menuitem', { name: '添加并选择读取方式' })).toBeVisible();
expect(screen.getByRole('menuitem', { name: '从电脑选择多个文件' })).toBeVisible();
view.unmount(); expect(revokePreview).toHaveBeenCalledWith('blob:local-image');
});
it('uploads only clipboard images and does not submit the message', async () => {
const { upload, call } = mockBrowserFiles(); browserFiles();
const image = new File(['image'], 'photo.png', { type: 'image/png' });
fireEvent.paste(screen.getByLabelText('消息'), { clipboardData: { files: [image, new File(['notes'], 'notes.txt', { type: 'text/plain' })] } });
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1));
expect(upload.mock.calls[0][0]).toBe(image);
await waitFor(() => expect(call).toHaveBeenCalledWith('confirmAttachment', expect.anything()));
expect(call.mock.calls.some(([name]) => name === 'submit' || name === 'preview')).toBe(false);
});
it('supports dropped files and blocks uploads while disabled', async () => {
const { upload } = mockBrowserFiles(); const view = browserFiles({ disabled: true });
const file = new File(['notes'], 'notes.txt', { type: 'text/plain' });
fireEvent.drop(screen.getByLabelText('消息'), { dataTransfer: { files: [file] } });
expect(upload).not.toHaveBeenCalled();
view.update({ scopeKey: 'same-thread', disabled: false });
fireEvent.drop(screen.getByLabelText('消息'), { dataTransfer: { files: [file] } });
await waitFor(() => expect(upload).toHaveBeenCalledTimes(1)); expect(upload.mock.calls[0][0]).toBe(file);
});
it('retries the same batch confirmation without uploading files or resolving the thread again', async () => {
const { upload, call } = mockBrowserFiles(); const base = call.getMockImplementation()!; let attempts = 0;
call.mockImplementation(async (operation, input) => {
if (operation === 'confirmAttachment' && ++attempts === 1) throw new Error('确认响应丢失');
return base(operation, input);
});
const view = browserFiles();
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })] } });
await screen.findByText('确认响应丢失'); expect(view.onSelected).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
await screen.findByRole('button', { name: '移除附件 photo.png' });
expect(upload).toHaveBeenCalledTimes(2); expect(view.ensureThread).toHaveBeenCalledTimes(1);
const confirmations = call.mock.calls.filter(([name]) => name === 'confirmAttachment');
expect(confirmations).toHaveLength(2); expect(confirmations[1]).toEqual(confirmations[0]);
});
it('keeps successfully uploaded temporary files when retrying a partly uploaded batch', async () => {
const { upload } = mockBrowserFiles(); const implementation = upload.getMockImplementation()!;
upload.mockResolvedValueOnce({ ...uploaded, object_name: 'temporary/photo.png', file_name: 'photo.png', file_type: 'image/png' })
.mockRejectedValueOnce(new Error('第二个文件上传失败')).mockImplementation(implementation);
browserFiles();
const files = [new File(['image'], 'photo.png', { type: 'image/png' }), new File(['notes'], 'notes.txt', { type: 'text/plain' })];
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } });
await screen.findByText('第二个文件上传失败'); fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
await screen.findByRole('button', { name: '移除附件 notes.txt' });
expect(upload.mock.calls.map(([file]) => file.name)).toEqual(['photo.png', 'notes.txt', 'notes.txt']);
});
it('ignores pending uploads after scope replacement and never clears a new busy owner', async () => {
const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void;
upload.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const view = browserFiles({ scopeKey: 'old' }); const newBusy = vi.fn();
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt')] } });
view.update({ scopeKey: 'new', onBusy: newBusy });
await act(async () => { finish(uploaded); });
expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled();
expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false);
expect(view.onBusy.mock.calls).toEqual([[true], [false]]); expect(newBusy).not.toHaveBeenCalled();
});
it('rejects a pending upload as soon as the auth actor changes before React rerenders', async () => {
useAuthStore.setState({ user: { userId: 'a', username: 'alice', tenantId: null, deptId: null, authorities: [] } });
const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void;
upload.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const view = browserFiles();
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt')] } });
const isActive = upload.mock.calls[0][1]!; expect(isActive()).toBe(true);
useAuthStore.setState({ user: { userId: 'b', username: 'bob', tenantId: null, deptId: null, authorities: [] } });
expect(isActive()).toBe(false); await act(async () => { finish(uploaded); });
expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled();
expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false); expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
expect(screen.getByRole('button', { name: '添加文件或图片' })).toBeEnabled();
});
it('releases the current lease when hidden and ignores late upload results while retaining an explicit retry', async () => {
const { upload, call } = mockBrowserFiles(); let finish!: (result: typeof uploaded) => void;
upload.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const view = browserFiles();
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt', { type: 'text/plain' })] } });
view.update({ scopeKey: 'same-thread', activeView: false });
expect(view.onBusy.mock.calls).toEqual([[true], [false]]);
await act(async () => { finish(uploaded); });
expect(view.ensureThread).not.toHaveBeenCalled(); expect(view.onSelected).not.toHaveBeenCalled();
expect(call.mock.calls.some(([name]) => name === 'confirmAttachment')).toBe(false);
view.update({ scopeKey: 'same-thread', activeView: true });
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
await screen.findByRole('button', { name: '移除附件 notes.txt' });
expect(view.ensureThread).toHaveBeenCalledTimes(1);
});
it('does not let a hidden operation clear the busy lease of a newer retry', async () => {
const { upload } = mockBrowserFiles(); let finishOld!: (result: typeof uploaded) => void; let finishNew!: (result: typeof uploaded) => void;
upload.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve; }))
.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve; }));
const view = browserFiles();
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files: [new File(['notes'], 'notes.txt', { type: 'text/plain' })] } });
view.update({ scopeKey: 'same-thread', activeView: false });
view.update({ scopeKey: 'same-thread', activeView: true });
fireEvent.click(screen.getByRole('button', { name: '重试添加' }));
expect(view.onBusy.mock.calls).toEqual([[true], [false], [true]]);
await act(async () => { finishOld(uploaded); });
expect(view.onBusy.mock.calls).toEqual([[true], [false], [true]]);
expect(view.ensureThread).not.toHaveBeenCalled();
await act(async () => { finishNew(uploaded); });
await screen.findByRole('button', { name: '移除附件 notes.txt' });
expect(view.onBusy.mock.calls).toEqual([[true], [false], [true], [false]]);
});
it('matches reordered confirmed thumbnails by identity and leaves unrelated files without a local preview', async () => {
const createPreview = vi.fn((file: File) => 'blob:' + file.name);
vi.stubGlobal('URL', class extends URL { static createObjectURL = createPreview; static revokeObjectURL = vi.fn(); });
const { call } = mockBrowserFiles(); const base = call.getMockImplementation()!;
call.mockImplementation(async (operation, input) => operation === 'confirmAttachment' ? { attachments: [
attachment('second', 'second.png'), attachment('first', 'first.png'), attachment('unknown', 'unknown.png'),
] } as never : base(operation, input));
browserFiles();
const files = [new File(['first'], 'first.png', { type: 'image/png' }), new File(['second'], 'second.png', { type: 'image/png' })];
fireEvent.change(screen.getByTestId('cloud-file-attachment-input'), { target: { files } });
await screen.findByRole('img', { name: 'second.png' });
expect(screen.getByRole('img', { name: 'second.png' })).toHaveAttribute('src', 'blob:second.png');
expect(screen.getByRole('img', { name: 'first.png' })).toHaveAttribute('src', 'blob:first.png');
expect(screen.queryByRole('img', { name: 'unknown.png' })).not.toBeInTheDocument();
expect(createPreview).toHaveBeenNthCalledWith(1, files[1]); expect(createPreview).toHaveBeenNthCalledWith(2, files[0]);
});
});