import { flushPromises, mount } from '@vue/test-utils';
|
import { reactive, ref } from 'vue';
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { isInputTableInteractiveTarget } from '../../InputTable/src/helpers/rowSelection';
|
import UploadFile from './UploadFile.vue';
|
|
const mocks = vi.hoisted(() => ({
|
preview: vi.fn(),
|
nativePreview: vi.fn(),
|
documentServer: 'http://office',
|
download: vi.fn(),
|
legacy: vi.fn(),
|
pack: vi.fn(),
|
open: vi.fn(),
|
image: vi.fn(),
|
save: vi.fn(),
|
warning: vi.fn(),
|
replace: vi.fn(),
|
context: {} as any,
|
}));
|
vi.mock('@jnpf/hooks', () => ({ useGlobSetting: () => ({ apiURL: '/dev' }) }));
|
vi.mock('@jnpf/ui', () => ({ createImgPreview: mocks.image }));
|
vi.mock('@jnpf/utils', () => ({ downloadByUrl: mocks.save, toFileSize: () => '' }));
|
vi.mock('@vben/stores', () => ({ useUserStore: () => ({ getUserInfo: {} }) }));
|
vi.mock('ant-design-vue', () => ({
|
Form: { useInjectFormItemContext: () => ({ onFieldChange: vi.fn() }) },
|
message: { warning: mocks.warning, error: vi.fn() },
|
}));
|
vi.mock('#/api/onlineDev/uploadTemplate', () => ({ selectUploadTemplate: vi.fn() }));
|
vi.mock('#/api/core/common', () => ({ getDownloadUrl: mocks.legacy, getPackDownloadUrl: mocks.pack }));
|
vi.mock('#/api/onlineDev/workflowFile', () => ({ getWorkflowPreview: mocks.preview, getWorkflowDownload: mocks.download }));
|
vi.mock('#/components/OnlyOfficeEditor/src/loadDocsApi', () => ({ getDocumentServerUrl: () => mocks.documentServer }));
|
vi.mock('#/locales', () => ({ $t: (key: string) => key }));
|
vi.mock('#/utils/jnpf', () => ({ onlineUtils: { openOfficeDocument: mocks.open } }));
|
vi.mock('./Preview.vue', () => ({ default: { template: '<div />', setup: (_: any, { expose }: any) => expose({ init: mocks.nativePreview }) } }));
|
vi.mock('./SimpleUploader/FileUploader.vue', () => ({ default: { template: '<div />' } }));
|
vi.mock('./useUploadOnlineEdit', () => ({
|
useUploadOnlineEdit: () => ({
|
blocked: ref(false),
|
failure: ref(''),
|
canDiscard: ref(false),
|
canEdit: () => false,
|
replace: mocks.replace,
|
previewEnabled: ref(true),
|
templateFeatureEnabled: ref(false),
|
accessContext: () => ({ ...mocks.context }),
|
formalFile: (file: any) => file,
|
}),
|
}));
|
const file = { fileId: 'A', name: 'a.docx', fileExtension: 'docx', fileSize: 1, url: '/raw/A', thumbUrl: '' };
|
const wrappers: ReturnType<typeof mount>[] = [];
|
function setup(controlled = true) {
|
const wrapper = mount(UploadFile, {
|
props: {
|
detailed: true,
|
value: [file],
|
showAllDownload: false,
|
...(controlled ? { fileAccess: { status: 'ready' as const, actions: { VIEW: true, DOWNLOAD: true } } } : {}),
|
},
|
global: { stubs: { AButton: true }, mocks: { $t: (key: string) => key } },
|
});
|
wrappers.push(wrapper);
|
return wrapper;
|
}
|
const viewSelector = '[title="component.upload.view"]';
|
const downloadSelector = '[title="component.upload.download"]';
|
beforeEach(() => {
|
vi.clearAllMocks();
|
mocks.documentServer = 'http://office';
|
mocks.context = reactive({ modelId: 'form', recordId: 'record', taskId: 'task', field: 'files' });
|
mocks.preview.mockResolvedValue({ data: { kind: 'office', ticket: 'ticket', fileName: 'a.docx', file } });
|
mocks.download.mockResolvedValue({ data: { downloadVo: { url: '/native-download' }, downloadName: 'a.docx' } });
|
mocks.legacy.mockResolvedValue({ data: { url: '/old-download' } });
|
});
|
afterEach(() => wrappers.splice(0).forEach((w) => w.unmount()));
|
it('uses workflow context for preview even with onlineEdit disabled', async () => {
|
await setup().get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.preview).toHaveBeenCalledWith(expect.objectContaining({ recordId: 'record', field: 'files', fileId: 'A' }), 'office');
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ bizScene: 'workflow.uploadPreview', file: expect.objectContaining({ fileId: 'ticket' }) }));
|
});
|
it('shows comments only when the workflow explicitly enables review', async () => {
|
const wrapper = setup();
|
await wrapper.setProps({ fileAccess: { status: 'ready', reviewEnabled: true, actions: { VIEW: true, REVIEW: false } } });
|
await wrapper.get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'viewComments' }));
|
mocks.open.mockClear();
|
await wrapper.setProps({ fileAccess: { status: 'ready', actions: { VIEW: true } } });
|
await wrapper.get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'view' }));
|
});
|
it.each(['0f857eea0b07410f99ea35dc816cf5d2', '2312c264f497ddbd86b04bb1805620c3'])(
|
'shows audit document comments without granting review for %s',
|
async (modelId) => {
|
mocks.context = reactive({ modelId, recordId: 'record', taskId: 'task', field: 'wenjian' });
|
const wrapper = setup();
|
await wrapper.setProps({ fileAccess: { status: 'ready', reviewEnabled: false, actions: { VIEW: true, REVIEW: false, EDIT: false } } });
|
await wrapper.get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'viewComments', bizScene: 'workflow.uploadPreview' }));
|
mocks.open.mockClear();
|
mocks.preview.mockResolvedValue({ data: { kind: 'office', ticket: 'ticket', fileName: 'a.xlsx', file } });
|
await wrapper.get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ mode: 'view' }));
|
},
|
);
|
it('keeps legacy preview and download on their existing paths', async () => {
|
const wrapper = setup(false);
|
await wrapper.get(viewSelector).trigger('click');
|
await wrapper.get(downloadSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.preview).not.toHaveBeenCalled();
|
expect(mocks.download).not.toHaveBeenCalled();
|
expect(mocks.legacy).toHaveBeenCalledWith('annex', 'A');
|
expect(mocks.open).toHaveBeenCalledWith({ file, fileType: 'annex', mode: 'view' });
|
});
|
it('does not fall back to a raw link when authorization fails', async () => {
|
mocks.preview.mockRejectedValue(new Error('denied'));
|
mocks.download.mockRejectedValue(new Error('denied'));
|
const wrapper = setup();
|
await wrapper.get(viewSelector).trigger('click');
|
await wrapper.get(downloadSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.open).not.toHaveBeenCalled();
|
expect(mocks.legacy).not.toHaveBeenCalled();
|
expect(mocks.save).not.toHaveBeenCalled();
|
});
|
it('drops a preview response after switching records', async () => {
|
let done!: (value: any) => void;
|
mocks.preview.mockReturnValue(
|
new Promise((resolve) => {
|
done = resolve;
|
}),
|
);
|
await setup().get(viewSelector).trigger('click');
|
mocks.context.recordId = 'other';
|
done({ data: { kind: 'office', ticket: 'old', fileName: 'a.docx' } });
|
await flushPromises();
|
expect(mocks.open).not.toHaveBeenCalled();
|
});
|
it('drops a pending download after revocation', async () => {
|
let done!: (value: any) => void;
|
mocks.download.mockReturnValue(
|
new Promise((resolve) => {
|
done = resolve;
|
}),
|
);
|
const wrapper = setup();
|
await wrapper.get(downloadSelector).trigger('click');
|
await wrapper.setProps({ fileAccess: { status: 'ready', actions: { VIEW: true, DOWNLOAD: false } } });
|
done({ data: { downloadVo: { url: '/native-download' }, downloadName: 'a.docx' } });
|
await flushPromises();
|
expect(mocks.save).not.toHaveBeenCalled();
|
});
|
it('blocks unsaved child rows instead of omitting their context', async () => {
|
mocks.context.parentField = 'rows';
|
mocks.context.rowId = '';
|
await setup().get(viewSelector).trigger('click');
|
expect(mocks.warning).toHaveBeenCalled();
|
expect(mocks.preview).not.toHaveBeenCalled();
|
});
|
it('uses the original image address after backend authorization', async () => {
|
mocks.preview.mockResolvedValue({ data: { kind: 'image', url: '/api/file/Image/annex/A' } });
|
await setup().get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.image).toHaveBeenCalledWith({ imageList: ['/dev/api/file/Image/annex/A'] });
|
});
|
|
it('uses the native download result without prefixing its URL again', async () => {
|
await setup().get(downloadSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.save).toHaveBeenCalledWith({ fileName: 'a.docx', url: '/native-download' });
|
expect(mocks.legacy).not.toHaveBeenCalled();
|
});
|
it('delegates platform preview to the existing modal when OnlyOffice is not configured', async () => {
|
mocks.documentServer = '';
|
mocks.preview.mockResolvedValue({ data: { kind: 'platform', file, url: 'http://preview/native' } });
|
await setup().get(viewSelector).trigger('click');
|
await flushPromises();
|
expect(mocks.preview).toHaveBeenCalledWith(expect.objectContaining({ fileId: 'A' }), 'platform');
|
expect(mocks.nativePreview).toHaveBeenCalledWith(file, { url: 'http://preview/native', download: expect.any(Function) });
|
expect(mocks.open).not.toHaveBeenCalled();
|
mocks.nativePreview.mock.calls[0]![1].download();
|
await flushPromises();
|
expect(mocks.download).toHaveBeenCalled();
|
expect(mocks.legacy).not.toHaveBeenCalled();
|
});
|
|
it('keeps readonly child attachment preview interactive without enabling mutation or selecting the row', async () => {
|
mocks.context.parentField = 'rows';
|
mocks.context.rowId = 'row-1';
|
const wrapper = setup();
|
await wrapper.setProps({
|
disabled: true,
|
detailed: false,
|
fileAccess: { status: 'ready', actions: { VIEW: true, DOWNLOAD: false, EDIT: false, ADD: false, REMOVE: false, REPLACE: false } },
|
});
|
const preview = wrapper.get(viewSelector);
|
expect(isInputTableInteractiveTarget(preview.element)).toBe(true);
|
expect(wrapper.get('a-button-stub').attributes('disabled')).toBe('true');
|
expect(wrapper.findAll(downloadSelector)).toHaveLength(0);
|
expect(wrapper.find('[title="在线编辑"]').exists()).toBe(false);
|
// 即使人工触发不可用动作,也不能修改附件。
|
await wrapper.get('[title="component.upload.del"]').trigger('click');
|
expect(mocks.replace).not.toHaveBeenCalled();
|
await preview.trigger('click');
|
await flushPromises();
|
expect(mocks.preview).toHaveBeenCalledWith(expect.objectContaining({ parentField: 'rows', rowId: 'row-1', fileId: 'A' }), 'office');
|
expect(mocks.open).toHaveBeenCalledOnce();
|
await wrapper.setProps({ fileAccess: { status: 'ready', actions: { VIEW: false } } });
|
expect(wrapper.findAll(viewSelector)).toHaveLength(0);
|
});
|
|
it('does not change legacy disabled cell click-through behavior', async () => {
|
const wrapper = setup(false);
|
await wrapper.setProps({ disabled: true });
|
expect(isInputTableInteractiveTarget(wrapper.get(viewSelector).element)).toBe(false);
|
expect(wrapper.find('.workflow-file-action').exists()).toBe(false);
|
});
|
|
it('activates single and batch download when node permission is granted and removes both on revocation', async () => {
|
mocks.context.parentField = 'rows';
|
mocks.context.rowId = 'row-2';
|
const wrapper = setup();
|
await wrapper.setProps({ disabled: true, showAllDownload: true, fileAccess: { status: 'ready', actions: { VIEW: true, DOWNLOAD: false } } });
|
expect(wrapper.findAll(downloadSelector)).toHaveLength(0);
|
expect(wrapper.find('.link-text').exists()).toBe(false);
|
await wrapper.setProps({ fileAccess: { status: 'ready', actions: { VIEW: true, DOWNLOAD: true } } });
|
expect(isInputTableInteractiveTarget(wrapper.get(downloadSelector).element)).toBe(true);
|
expect(isInputTableInteractiveTarget(wrapper.get('.link-text').element)).toBe(true);
|
await wrapper.get(downloadSelector).trigger('click');
|
await flushPromises();
|
await wrapper.get('.link-text').trigger('click');
|
await flushPromises();
|
expect(mocks.download).toHaveBeenCalledTimes(2);
|
expect(mocks.download).toHaveBeenLastCalledWith([expect.objectContaining({ parentField: 'rows', rowId: 'row-2', fileId: 'A' })]);
|
expect(mocks.save).toHaveBeenCalledTimes(2);
|
await wrapper.setProps({ fileAccess: { status: 'ready', actions: { VIEW: true, DOWNLOAD: false } } });
|
expect(wrapper.findAll(downloadSelector)).toHaveLength(0);
|
expect(wrapper.find('.link-text').exists()).toBe(false);
|
});
|