刘光辉
3 小时以前 cf11e63b659afcc9792ab3e395f1b45d844431a8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
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);
});