Files
admin-web/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts
T

197 lines
15 KiB
TypeScript

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<typeof import('../api/deliveryDocuments')>(), 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<ReturnType<typeof mountDialog>['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.find('[aria-label="派送资料摘要"]').exists()).toBe(false)
expect(wrapper.findAll('.document-file')).toHaveLength(1)
expect(wrapper.findAll('.document-files th').map(cell => cell.text())).toEqual(['操作', '文件名称'])
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')
expect(wrapper.get('.document-file-pending').text()).toBe('待保存')
await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(wrapper.findAll('.document-file')).toHaveLength(0)
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.findAll('[data-category="RECEIPT"] .document-file')).toHaveLength(2)
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.findAll('.document-file')).toHaveLength(2)
expect(reopened.find('.document-file-pending').exists()).toBe(false)
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.findAll('.document-file')).toHaveLength(2)
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.findAll('.document-file')).toHaveLength(1)
expect(wrapper.find('.document-file-pending').exists()).toBe(false)
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.findAll('.document-file')).toHaveLength(1)
expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
})