import ElementPlus, { ElMessage } from 'element-plus' import { defineComponent, h } from 'vue' import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue' import { commonPhase5Stubs } from '../pages/phase5TestStubs' import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, type DeliveryDocuments } from '../api/deliveryDocuments' const can = vi.hoisted(() => vi.fn(() => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn(), uploadDeliveryDocument: vi.fn(), downloadDeliveryDocument: vi.fn() })) const dialogStub = defineComponent({ name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'], setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, '保存资料') : null, h('button', { class: 'close', onClick: () => emit('update:modelValue', false) }, '关闭')]) : null }, }) const uploadStub = defineComponent({ name: 'ElUpload', props: ['onChange', 'disabled'], setup(props, { slots }) { return () => h('label', [slots.default?.(), h('input', { type: 'file', disabled: props.disabled, onChange: (event: Event) => props.onChange({ raw: (event.target as HTMLInputElement).files?.[0] }) })]) }, }) const originalFile = { fileId: '9007199254740995', fileName: 'original.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-09-09T09:00:00' } function response(): DeliveryDocuments { return { deliveryNo: 'DL/1', customerId: '100', version: 9, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...originalFile }] : [] })) } } function mountDialog() { const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: dialogStub, ElUpload: uploadStub } } }) return wrapper } async function choose(input: ReturnType['find']>, file: File) { Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) await input.trigger('change'); await flushPromises() } describe('delivery document drafts', () => { beforeEach(() => { can.mockReset().mockReturnValue(true) vi.mocked(getDeliveryDocuments).mockReset().mockResolvedValue(response()) vi.mocked(saveDeliveryDocuments).mockReset().mockResolvedValue(response()) vi.mocked(uploadDeliveryDocument).mockReset().mockResolvedValue({ ...originalFile, fileId: '9007199254740996', fileName: 'new.pdf' }) }) it('loads once and stages replacement and deletion until a full versioned save', async () => { const wrapper = mountDialog(); await flushPromises() expect(getDeliveryDocuments).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('1/3 已上传') await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['next'], 'new.pdf')) expect(saveDeliveryDocuments).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('original.pdf') expect(wrapper.text()).toContain('new.pdf') await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click') expect(wrapper.text()).toContain('0/3 已上传') await wrapper.find('.save').trigger('click'); await flushPromises() expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) }) expect(wrapper.emitted('saved')).toHaveLength(1) wrapper.unmount() }) it('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => { const success = vi.spyOn(ElMessage, 'success') const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' } const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' } const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' } vi.mocked(uploadDeliveryDocument).mockResolvedValueOnce(receipt).mockResolvedValueOnce(courier).mockResolvedValueOnce(replacement) const wrapper = mount(DeliveryDocumentsDialog, { attachTo: document.body, props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus] } }) const body = new DOMWrapper(document.body) try { await flushPromises() const dialog = body.find('[role="dialog"]') expect(dialog.findAll('[data-category]')).toHaveLength(3) await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['pod'], 'pod.pdf')) await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['courier'], 'courier.pdf')) expect(dialog.text()).toContain('2/3 已上传') await choose(dialog.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['signed'], 'signed-bol.pdf')) await dialog.find('[data-category="RECEIPT"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click') expect(dialog.text()).not.toContain('pod.pdf') expect(dialog.text()).toContain('courier.pdf') expect(saveDeliveryDocuments).not.toHaveBeenCalled() await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises() expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] }) expect(wrapper.emitted('saved')).toHaveLength(1) expect(success).toHaveBeenCalledWith('签收文件已保存,派送单已自动完成') const persisted = response() persisted.version = 10; persisted.uploadedCategoryCount = 2 persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' })) vi.mocked(getDeliveryDocuments).mockResolvedValue(persisted) await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() const reopened = body.find('[role="dialog"]') expect(reopened.text()).toContain('2/3 已上传') expect(reopened.text()).toContain('signed-bol.pdf') expect(reopened.text()).toContain('courier.pdf') expect(reopened.text()).not.toContain('original.pdf') expect(getDeliveryDocuments).toHaveBeenCalledTimes(2) } finally { wrapper.unmount() } }) it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => { const wrapper = mountDialog(); await flushPromises() await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf')) expect(wrapper.text()).toContain('2/3 已上传') await wrapper.find('.close').trigger('click') await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() expect(saveDeliveryDocuments).not.toHaveBeenCalled() expect(wrapper.emitted('saved')).toBeUndefined() expect(wrapper.text()).toContain('1/3 已上传') expect(wrapper.text()).not.toContain('new.pdf') wrapper.unmount() }) it('preserves the draft and idempotency key on a failed save, and prevents duplicate submissions', async () => { const wrapper = mountDialog(); await flushPromises() let reject!: (error: Error) => void vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest })) await wrapper.find('.save').trigger('click'); await wrapper.find('.save').trigger('click') expect(saveDeliveryDocuments).toHaveBeenCalledTimes(1) reject(new Error('保存失败')); await flushPromises() expect(wrapper.text()).toContain('original.pdf') expect(wrapper.emitted('saved')).toBeUndefined() const key = vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].idempotencyKey await wrapper.find('.save').trigger('click'); await flushPromises() expect(vi.mocked(saveDeliveryDocuments).mock.calls[1]![2].idempotencyKey).toBe(key) wrapper.unmount() }) it('retains the existing file after replacement upload fails and rejects invalid file sizes', async () => { const wrapper = mountDialog(); await flushPromises() vi.mocked(uploadDeliveryDocument).mockRejectedValueOnce(new Error('上传失败')) await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'bad.pdf')) expect(wrapper.text()).toContain('original.pdf') await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File([], 'empty.bin')) const oversized = new File(['data'], 'large.bin'); Object.defineProperty(oversized, 'size', { value: 52428801 }) await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), oversized) expect(uploadDeliveryDocument).toHaveBeenCalledTimes(1) const exact = new File(['data'], 'valid.bin'); Object.defineProperty(exact, 'size', { value: 52428800 }) await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), exact) expect(uploadDeliveryDocument).toHaveBeenCalledTimes(2) wrapper.unmount() }) it.each(['permission', 'action'])('is read-only without the required %s', async kind => { if (kind === 'permission') can.mockReturnValue(false) else vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), allowedActions: [] }) const wrapper = mountDialog(); await flushPromises() expect(wrapper.find('.save').exists()).toBe(false) expect(wrapper.find('input[type="file"]').exists()).toBe(false) expect(wrapper.text()).toContain('original.pdf') expect(saveDeliveryDocuments).not.toHaveBeenCalled() wrapper.unmount() }) it('blocks retries after a version conflict without losing draft files', async () => { vi.mocked(saveDeliveryDocuments).mockRejectedValue({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本已变化', requestId: 'req-1', traceId: 'trace-1' } } }) const wrapper = mountDialog(); await flushPromises() await wrapper.find('.save').trigger('click'); await flushPromises() expect(wrapper.find('.save').attributes('disabled')).toBeDefined() expect(wrapper.text()).toContain('original.pdf') expect(wrapper.emitted('saved')).toBeUndefined() wrapper.unmount() }) it('limits each category to twenty files while allowing replacement', async () => { const snapshot = response() snapshot.categories.find(item => item.category === 'BOL')!.files = Array.from({ length: 20 }, (_, index) => ({ ...originalFile, fileId: String(index + 1) })) snapshot.categories.find(item => item.category === 'BOL')!.fileCount = 20 vi.mocked(getDeliveryDocuments).mockResolvedValue(snapshot) const wrapper = mountDialog(); await flushPromises() expect(wrapper.find('[data-category="BOL"] header input[type="file"]').attributes('disabled')).toBeDefined() await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'replace.pdf')) await wrapper.find('.save').trigger('click'); await flushPromises() expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toHaveLength(20) expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toContain('9007199254740996') wrapper.unmount() }) it('ignores a late save response after switching to another customer delivery', async () => { let finish!: (value: DeliveryDocuments) => void vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise(resolve => { finish = resolve })) const wrapper = mountDialog(); await flushPromises() await wrapper.find('.save').trigger('click') vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), deliveryNo: 'DL/2', customerId: '200', bolNo: 'BOL-2' }) await wrapper.setProps({ deliveryNo: 'DL/2', customerId: '200' }); await flushPromises() finish(response()); await flushPromises() expect(wrapper.emitted('saved')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.find('.save').exists()).toBe(true) wrapper.unmount() }) it('ignores a late upload failure after closing and reopening on another delivery', async () => { let fail!: (reason: Error) => void vi.mocked(uploadDeliveryDocument).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject })) const wrapper = mountDialog(); await flushPromises() await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['data'], 'pending.pdf')) await wrapper.setProps({ modelValue: false }); await flushPromises() await wrapper.setProps({ modelValue: true, deliveryNo: 'DL/2', customerId: '200' }); await flushPromises() fail(new Error('old upload failed')); await flushPromises() expect(wrapper.text()).not.toContain('old upload failed') expect(wrapper.text()).toContain('1/3 已上传') expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined() wrapper.unmount() }) })