168 lines
17 KiB
TypeScript
168 lines
17 KiB
TypeScript
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import RelabelOrdersView from './RelabelOrdersView.vue'
|
||
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import { ApiResponseError } from '@/shared/api/http'
|
||
import type { RelabelDetailDto } from '../api/relabelOrders'
|
||
|
||
const api = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), create: vi.fn(), status: vi.fn(), customers: vi.fn(), warehouses: vi.fn(), pdf: vi.fn(), upload: vi.fn(), documents: vi.fn(), complete: vi.fn(), task: vi.fn(), rework: vi.fn() }))
|
||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
|
||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
|
||
vi.mock('../api/relabelOrders', () => ({ listRelabelOrders: api.list, getRelabelOrder: api.detail, createRelabelOrder: api.create, changeRelabelOrderStatus: api.status, listRelabelWarehouseOptions: api.warehouses, downloadRelabelPdf: api.pdf, uploadRelabelPdf: api.upload, downloadRelabelDocument: api.documents, confirmRelabelCompletion: api.complete, getRelabelReviewTask: api.task, returnRelabelForRework: api.rework, downloadRelabelCompletionPhoto: vi.fn() }))
|
||
|
||
enableAutoUnmount(afterEach)
|
||
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
|
||
function detail(): RelabelDetailDto { return { application: { id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', status: 'PENDING_REVIEW', chargeAmount: 20, totalPackageCount: 1, totalFileId: '9007199254740993', totalFileName: '总换标.pdf' }, items: [{ id: '9007199254740994', skuCode: 'SKU-A', productNameZh: '商品甲', shipmentNo: 'SHIP-1', labelRequirement: '换SKU标', relabelFileId: '9007199254740995', relabelFileName: '明细换标.pdf', destinationFbaWarehouseCode: null, destinationWarehouseId: '9007199254740999', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] } }
|
||
beforeEach(() => {
|
||
vi.resetAllMocks(); can.mockReturnValue(true)
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||
api.list.mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }], total: 1 })
|
||
api.detail.mockImplementation(async () => detail()); api.create.mockImplementation(async () => detail()); api.status.mockResolvedValue(null)
|
||
api.customers.mockResolvedValue({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 })
|
||
api.warehouses.mockResolvedValue({ records: [{ id: 'W1', warehouseCode: 'W-1', warehouseName: '海外收货仓', status: 'ACTIVE' }] })
|
||
api.pdf.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); api.upload.mockImplementation(async () => detail())
|
||
api.complete.mockResolvedValue(null); api.task.mockResolvedValue({ version: 0 }); api.rework.mockResolvedValue(null)
|
||
URL.createObjectURL = vi.fn().mockReturnValue('blob:relabel-pdf'); URL.revokeObjectURL = vi.fn()
|
||
})
|
||
function field(wrapper: Pick<VueWrapper, 'findAllComponents'>, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
|
||
async function openForm(action = '新增换标单') { const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } }); await flushPromises(); await wrapper.findAll('button').find(button => button.text() === action)!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||
async function fill(dialog: VueWrapper) {
|
||
for (const [label, value] of [['关联订单', 'CO-1'], ['商品SKU', 'SKU-1'], ['中文品名', '商品一'], ['联系人', '联系人'], ['联系电话', '测试电话'], ['货件号', 'SHIP-1'], ['新SKU', 'NEW-1']]) await field(dialog, label!).get('input').setValue(value!)
|
||
for (const [label, value] of [['客户', 'C1'], ['公共海外收货仓', 'W1'], ['贴标要求', '换SKU标']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
|
||
field(dialog, '箱数').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||
field(dialog, '换标费用(CNY)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||
field(dialog, '截止时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00'); await flushPromises()
|
||
}
|
||
async function openDetail() { const wrapper = mount(RelabelOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
|
||
async function chooseFile(input: ReturnType<VueWrapper['get']>, file: File) { Object.defineProperty(input.element, 'files', { value: [file], configurable: true }); await input.trigger('change'); await flushPromises() }
|
||
|
||
describe('native relabel operation dialogs', () => {
|
||
it('locks candidate loading and retains all fields through fullscreen creation', async () => {
|
||
let resolve!: (value: { records: Array<{ id: string; tenantName: string; status: string }>; pages: number }) => void
|
||
api.customers.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||
const { dialog } = await openForm()
|
||
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
|
||
expect(field(dialog, '关联订单').get('input').attributes('disabled')).toBeDefined()
|
||
resolve({ records: [{ id: 'C1', tenantName: '客户甲', status: 'ACTIVE' }], pages: 1 }); await flushPromises()
|
||
expect(dialog.get('[aria-label="换标单生成方式"]').findAll('input')).toHaveLength(0)
|
||
await fill(dialog); await field(dialog, '备注').get('textarea').setValue(' 本次备注 ')
|
||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||
expect((field(dialog, '关联订单').get('input').element as HTMLInputElement).value).toBe('CO-1')
|
||
dialog.vm.$emit('confirm'); await flushPromises()
|
||
expect(api.create).toHaveBeenCalledExactlyOnceWith('C1', { customerOrderNo: 'CO-1', operationWarehouseId: 'W1', labelDeadline: '2026-11-01T10:00:00', contactName: '联系人', contactPhone: '测试电话', chargeAmount: 20, remark: '本次备注', idempotencyKey: expect.any(String), items: [{ skuCode: 'SKU-1', productNameZh: '商品一', totalPackageCount: 2, notifiedPackageCount: 2, shipmentNo: 'SHIP-1', newSkuCode: 'NEW-1', labelRequirement: '换SKU标' }] })
|
||
})
|
||
it('prevents duplicate creation and retains inputs and the original creation key after failure', async () => {
|
||
let reject!: (cause: Error) => void
|
||
api.create.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail }))
|
||
const { dialog } = await openForm(); await fill(dialog)
|
||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||
expect(api.create).toHaveBeenCalledOnce(); expect(field(dialog, '商品SKU').get('input').attributes('disabled')).toBeDefined()
|
||
const key = api.create.mock.calls[0]![1].idempotencyKey
|
||
reject(new ApiResponseError('创建未确认', 'A0503', 503, null, 'relabel-create-request')); await flushPromises()
|
||
expect(dialog.text()).toContain('relabel-create-request'); expect((field(dialog, '商品SKU').get('input').element as HTMLInputElement).value).toBe('SKU-1')
|
||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create.mock.calls[1]![1].idempotencyKey).toBe(key)
|
||
})
|
||
it('shows every original SKU as plain facts and requires a rejection reason', async () => {
|
||
const { dialog } = await openForm('审核')
|
||
expect(dialog.props('title')).toBe('审核换标单 · RL-1')
|
||
const facts = dialog.get('[aria-label="换标审核原始资料"]')
|
||
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
|
||
expect(facts.text()).toContain('SKU-A、SKU-B'); expect(facts.text()).toContain('NEW-A × 10 件;NEW-B × 5 件')
|
||
const action = field(dialog, '审核动作').getComponent({ name: 'ElSelect' })
|
||
expect(action.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['PENDING_PROCESSING', 'REJECTED'])
|
||
action.vm.$emit('update:modelValue', 'REJECTED'); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.status).not.toHaveBeenCalled()
|
||
await field(dialog, '备注').get('textarea').setValue('驳回原因'); dialog.vm.$emit('confirm'); await flushPromises()
|
||
expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'REJECTED', '驳回原因')
|
||
})
|
||
it('shows independent resource failures and reloads each original candidate source', async () => {
|
||
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'customer-request'))
|
||
api.warehouses.mockRejectedValueOnce(new ApiResponseError('仓库失败', 'A0503', 503, null, 'warehouse-request'))
|
||
const { dialog } = await openForm(); expect(dialog.text()).toContain('customer-request'); expect(dialog.text()).toContain('warehouse-request')
|
||
expect(dialog.props('confirmDisabled')).toBe(true); dialog.vm.$emit('confirm'); await flushPromises(); expect(api.create).not.toHaveBeenCalled()
|
||
dialog.findAllComponents(LoadErrorState).find(item => item.props('title') === '客户候选读取失败')!.vm.$emit('retry'); await flushPromises()
|
||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(api.warehouses).toHaveBeenLastCalledWith()
|
||
expect(dialog.findComponent(LoadErrorState).exists()).toBe(false)
|
||
})
|
||
it.each(['作废', '确认换标完成', '退回重新贴标'] as const)('keeps the original %s confirmation, business reference and command', async action => {
|
||
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
|
||
api.detail.mockResolvedValueOnce(review)
|
||
const { wrapper } = await openDetail()
|
||
await wrapper.findAll('button').find(button => button.text().includes(action))!.trigger('click'); await flushPromises()
|
||
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)
|
||
expect(element).toBeDefined(); const box = new DOMWrapper(element!)
|
||
expect(box.text()).toContain('RL-1'); expect(element!.classList.contains('admin-density-confirm')).toBe(true)
|
||
if (action === '退回重新贴标') await box.get('textarea').setValue(' 需要重新贴标 ')
|
||
await box.get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
|
||
if (action === '作废') expect(api.status).toHaveBeenCalledExactlyOnceWith('RL-1', 'CANCELLED', '平台作废换标单')
|
||
else if (action === '确认换标完成') expect(api.complete).toHaveBeenCalledExactlyOnceWith('RL-1')
|
||
else expect(api.rework).toHaveBeenCalledExactlyOnceWith('RL-1', 0, '需要重新贴标', expect.any(String))
|
||
})
|
||
it('locks the competing review action until completion confirmation finishes', async () => {
|
||
const review = detail(); review.application.status = 'CUSTOMER_CONFIRM_PENDING'; review.completionEvidence = { taskId: '1', photoFileIds: ['11'] }
|
||
api.detail.mockResolvedValueOnce(review)
|
||
let resolve!: (value: null) => void
|
||
api.complete.mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||
const { wrapper } = await openDetail()
|
||
await wrapper.findAll('button').find(button => button.text().includes('确认换标完成'))!.trigger('click'); await flushPromises()
|
||
const element = Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!
|
||
await new DOMWrapper(element).get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises()
|
||
const rework = wrapper.findAll('button').find(button => button.text().includes('退回重新贴标'))!
|
||
expect(rework.attributes('disabled')).toBeDefined(); await rework.trigger('click'); await flushPromises()
|
||
expect(api.complete).toHaveBeenCalledOnce(); expect(api.task).not.toHaveBeenCalled()
|
||
resolve(null); await flushPromises()
|
||
})
|
||
it('keeps readonly file previews available while hiding upload and internal warehouse identifiers', async () => {
|
||
can.mockImplementation(code => code.endsWith(':list'))
|
||
const { wrapper, dialog } = await openDetail()
|
||
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0); expect(wrapper.text()).not.toContain('9007199254740999')
|
||
expect(wrapper.get('.relabel-mark-list table th').text()).toBe('操作(明细PDF)')
|
||
await wrapper.findAll('button').find(button => button.text().includes('直接预览'))!.trigger('click'); await flushPromises()
|
||
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined)
|
||
expect(dialog.props('fullscreenable')).toBe(true); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
|
||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||
dialog.vm.$emit('update:modelValue', false); await flushPromises(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:relabel-pdf')
|
||
await wrapper.get('.relabel-mark-list').findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises()
|
||
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740995', 'ITEM', '9007199254740994')
|
||
})
|
||
it('discards closed preview responses and retries failed reads with the same file scope', async () => {
|
||
let resolve!: (value: Blob) => void
|
||
api.pdf.mockReturnValueOnce(new Promise(done => { resolve = done })).mockRejectedValueOnce(new ApiResponseError('读取失败', 'A0503', 503, null, 'pdf-request'))
|
||
const { wrapper, dialog } = await openDetail(); const button = wrapper.findAll('button').find(item => item.text().includes('直接预览'))!
|
||
await button.trigger('click'); await flushPromises(); expect(dialog.props('loading')).toBe(true); expect(dialog.find('iframe').exists()).toBe(false)
|
||
dialog.vm.$emit('update:modelValue', false); await flushPromises(); resolve(new Blob(['late'], { type: 'application/pdf' })); await flushPromises()
|
||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||
await button.trigger('click'); await flushPromises(); expect(dialog.text()).toContain('pdf-request')
|
||
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
|
||
expect(api.pdf).toHaveBeenLastCalledWith('RL-1', '9007199254740993', 'TOTAL', undefined); expect(dialog.get('iframe').attributes('src')).toBe('blob:relabel-pdf')
|
||
})
|
||
it('uploads total and item PDFs through their original scope and blocks duplicate uploads', async () => {
|
||
const withoutItemFile = detail(); withoutItemFile.items[0]!.relabelFileId = null; withoutItemFile.items[0]!.relabelFileName = null
|
||
api.detail.mockResolvedValueOnce(withoutItemFile)
|
||
let resolve!: (value: RelabelDetailDto) => void
|
||
api.upload.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(withoutItemFile)
|
||
const { wrapper } = await openDetail(); const file = new File(['pdf'], 'new.pdf', { type: 'application/pdf' })
|
||
const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
|
||
await chooseFile(input, file); expect(input.attributes('disabled')).toBeDefined()
|
||
await input.trigger('change'); await flushPromises(); expect(api.upload).toHaveBeenCalledExactlyOnceWith('RL-1', file, 'TOTAL', undefined)
|
||
resolve(withoutItemFile); await flushPromises()
|
||
await chooseFile(wrapper.get('.relabel-mark-list input[type="file"]'), file)
|
||
expect(api.upload).toHaveBeenLastCalledWith('RL-1', file, 'ITEM', '9007199254740994')
|
||
})
|
||
it('preserves PDF file checks and shows inline upload diagnostics without changing the current details', async () => {
|
||
api.upload.mockRejectedValueOnce(new ApiResponseError('上传失败', 'A0503', 503, null, 'upload-request'))
|
||
const { wrapper } = await openDetail(); const input = wrapper.get('[aria-label="总换标文件"] input[type="file"]')
|
||
await chooseFile(input, new File(['png'], 'bad.png', { type: 'image/png' })); expect(api.upload).not.toHaveBeenCalled()
|
||
const tooBig = new File(['pdf'], 'large.pdf', { type: 'application/pdf' }); Object.defineProperty(tooBig, 'size', { value: 50 * 1024 * 1024 + 1 })
|
||
await chooseFile(input, tooBig); expect(api.upload).not.toHaveBeenCalled()
|
||
await chooseFile(input, new File(['pdf'], 'new.pdf', { type: 'application/pdf' }))
|
||
expect(wrapper.text()).toContain('upload-request'); expect(wrapper.text()).toContain('总换标.pdf'); expect(input.attributes('disabled')).toBeUndefined()
|
||
})
|
||
})
|