Files
admin-web/src/modules/overseas-warehouse/pages/CargoDiscardDialogs.test.ts
T
2026-10-20 17:24:50 +08:00

178 lines
16 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 AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { ApiResponseError } from '@/shared/api/http'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { CargoDiscardDetail, CargoDiscardSummary } from '../api/cargoDiscards'
import CargoDiscardsView from './CargoDiscardsView.vue'
const api = vi.hoisted(() => ({ customers: vi.fn(), list: vi.fn(), detail: vi.fn(), review: vi.fn(), photo: vi.fn(), statistics: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: api.customers }))
vi.mock('../api/cargoDiscards', () => ({ listCargoDiscards: api.list, getCargoDiscard: api.detail, reviewCargoDiscard: api.review, downloadCargoDiscardPhoto: api.photo, getCargoDiscardStatistics: api.statistics }))
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close(); vi.unstubAllGlobals() })
function summary(overrides: Partial<CargoDiscardSummary> = {}): CargoDiscardSummary { return { id: '9007199254740993', customerId: '9007199254740994', discardNo: 'CD-001', inboundCodes: 'INBOUND-1', overseasWarehouseName: '洛杉矶仓', outboundNo: 'OUT-1', status: 'PENDING_REVIEW', totalPackages: 2, totalFee: 20, currency: 'USD', ...overrides } }
function detail(): CargoDiscardDetail { return { summary: summary(), remark: '废弃原因与备注', items: [{ id: '9007199254740995', inboundCode: 'INBOUND-1', skuCode: 'SKU-LONG-ORIGINAL-001', productName: '原始完整品名'.repeat(10), discardPackages: 2, cartonMarks: ['MARK-001', 'MARK-002'] }], histories: [{ toStatus: 'PENDING_REVIEW', createdAt: '2026-10-20T09:00:00', remark: '已提交申请' }], photos: [] } }
function photo(fileId = '9007199254740996') { return { fileId, palletNo: 'PALLET-1', fileName: '废弃现场.png', createdAt: '2026-10-20T09:00:00', submittedAt: '2026-10-20T09:01:00' } }
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
api.customers.mockResolvedValue({ records: [{ id: '9007199254740994', tenantName: '客户一' }] })
api.statistics.mockResolvedValue({ orderCount: 1, skuCount: 1, requestedPackages: 2, actualPackages: null, requestedPieces: null, actualPieces: null, palletCount: null })
api.list.mockResolvedValue({ records: [summary()], total: 1 })
api.detail.mockImplementation(async () => detail()); api.review.mockResolvedValue(detail())
api.photo.mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
URL.createObjectURL = vi.fn().mockReturnValue('blob:discard-photo'); URL.revokeObjectURL = vi.fn()
})
async function page() { const wrapper = mount(CargoDiscardsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); return wrapper }
function row(wrapper: VueWrapper, no = 'CD-001') { return wrapper.findAll('.el-table__body tr').filter(item => item.text().includes(no)).at(-1)! }
async function open() { const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function confirmation() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-message-box')).at(-1)!) }
async function accept() { await confirmation().get('.el-message-box__btns .el-button--primary').trigger('click'); await flushPromises() }
function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: string) { return wrapper.findAllComponents(LoadErrorState).find(item => item.props('title') === title)! }
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
describe('native cargo discard detail and approval', () => {
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
const { wrapper, dialog } = await open()
expect(api.statistics).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('全筛选')
expect(wrapper.text()).not.toContain('统计未加载')
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
const facts = dialog.get('[aria-label="废弃单基础资料"]')
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
expect(facts.findAll('input, textarea, select')).toHaveLength(0)
const clamps = dialog.findAllComponents(LineClampText).map(item => item.props('content'))
expect(clamps).toContain('原始完整品名'.repeat(10)); expect(clamps).toContain('MARK-001、MARK-002'); expect(clamps).toContain('SKU-LONG-ORIGINAL-001')
expect(dialog.get('[aria-label="废弃状态流转"]').text()).toContain('已提交申请')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.findAll('input, textarea')).toHaveLength(0); expect(api.review).not.toHaveBeenCalled()
})
it('locks reading and retries the original business number and customer scope after failure', async () => {
let reject!: (error: Error) => void
api.detail.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
const { dialog } = await open()
expect(dialog.props('confirmLoading')).toBe(true); expect(dialog.findComponent({ name: 'ElDialog' }).props('showClose')).toBe(false)
expect(dialog.get('[role="status"]').text()).toContain('正在读取'); expect(dialog.find('[aria-label="废弃单基础资料"]').exists()).toBe(false)
reject(new ApiResponseError('读取失败', 'A0503', 503, null, 'discard-detail-request')); await flushPromises()
expect(dialog.text()).toContain('discard-detail-request'); await retry(errorState(dialog, '废弃单详情加载失败'))
expect(api.detail.mock.calls).toEqual([['CD-001', '9007199254740994'], ['CD-001', '9007199254740994']])
expect(dialog.get('[aria-label="废弃单基础资料"]').text()).toContain('CD-001')
})
it('confirms approval with the business number and sends the original APPROVE request only once', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.review.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); const button = row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await button.trigger('click'); await flushPromises()
expect(confirmation().text()).toContain('CD-001'); expect(confirmation().text()).toContain('推送 PDA')
expect(confirmation().classes()).toContain('admin-density-confirm'); expect(api.review).not.toHaveBeenCalled()
await accept(); await button.trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledExactlyOnceWith('CD-001', 'APPROVE', undefined, '9007199254740994')
expect(row(wrapper).findAll('button').every(item => item.attributes('disabled') !== undefined)).toBe(true)
resolve(detail()); await flushPromises(); expect(api.list).toHaveBeenCalledTimes(2)
})
it('preserves the list and diagnostics on approval failure, and ignores native cancel and close', async () => {
api.review.mockRejectedValueOnce(new ApiResponseError('审核未确认', 'A0503', 503, null, 'discard-review-request'))
const wrapper = await page()
const approve = () => row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!
await approve().trigger('click'); await flushPromises(); await accept()
expect(wrapper.text()).toContain('discard-review-request'); expect(row(wrapper).text()).toContain('CD-001')
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__btns .el-button').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce()
await approve().trigger('click'); await flushPromises(); await confirmation().get('.el-message-box__headerbtn').trigger('click'); await flushPromises()
expect(api.review).toHaveBeenCalledOnce(); expect(errorState(wrapper, '审核失败')).toBeUndefined()
})
it('keeps list access readonly, checks approval status and rechecks permission after confirmation', async () => {
can.mockImplementation(code => code === permission.discardList)
const readonly = await page(); expect(row(readonly).findAll('button').map(item => item.text())).toEqual(['详情'])
can.mockReturnValue(true); api.list.mockResolvedValueOnce({ records: [summary({ status: 'APPROVED' })], total: 1 })
const approved = await page(); expect(row(approved).findAll('button').map(item => item.text())).toEqual(['详情'])
const wrapper = await page(); await row(wrapper).findAll('button').find(item => item.text() === '审核并推送 PDA')!.trigger('click'); await flushPromises()
can.mockImplementation(code => code !== permission.discardApprove); await accept(); expect(api.review).not.toHaveBeenCalled()
})
it('downloads each photo in its explicit scope and keeps native viewer order, size and cleanup', async () => {
const result = detail(); result.photos = [photo(), photo('9007199254740997')]
const first = new Blob(['first'], { type: 'image/png' }); const second = new Blob(['second'], { type: 'image/png' })
let resolve!: (value: Blob) => void
api.detail.mockResolvedValueOnce(result)
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done })).mockResolvedValueOnce(second)
vi.mocked(URL.createObjectURL).mockImplementation(blob => blob === first ? 'blob:first' : 'blob:second')
const { dialog } = await open(); expect(dialog.text()).toContain('照片加载中')
resolve(first); await flushPromises()
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740997', '9007199254740994']])
const images = dialog.findAllComponents({ name: 'ElImage' }); expect(images).toHaveLength(2)
expect(images[0]!.props()).toMatchObject({ src: 'blob:first', previewSrcList: ['blob:first', 'blob:second'], initialIndex: 0, previewTeleported: true, fit: 'cover' })
expect(images[1]!.props('initialIndex')).toBe(1)
await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:first'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:second')
})
it('shows per-photo diagnostics and retries the same file, business number and customer', async () => {
const result = detail(); result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
api.photo.mockRejectedValueOnce(new ApiResponseError('图片失败', 'A0503', 503, null, 'discard-photo-request'))
const { dialog } = await open(); expect(dialog.text()).toContain('discard-photo-request')
await retry(errorState(dialog, '废弃照片加载失败'))
expect(api.photo.mock.calls).toEqual([['CD-001', '9007199254740996', '9007199254740994'], ['CD-001', '9007199254740996', '9007199254740994']])
expect(dialog.findComponent({ name: 'ElImage' }).exists()).toBe(true)
})
it('ignores late photos after closing and switching to another business number', async () => {
const old = detail(); old.photos = [photo()]; api.detail.mockResolvedValueOnce(old)
let resolve!: (value: Blob) => void
api.photo.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper, dialog } = await open(); await dialog.get('.app-dialog__footer .el-button').trigger('click'); await flushPromises()
const next = detail(); next.summary = summary({ discardNo: 'CD-002', customerId: 'CUSTOMER-2' }); next.photos = [photo()]
api.list.mockResolvedValueOnce({ records: [next.summary], total: 1 }); api.detail.mockResolvedValueOnce(next)
wrapper.findComponent(QueryToolbar).vm.$emit('reset', { customerId: '', discardNo: '', inboundCode: '', status: '' }); await flushPromises()
await row(wrapper, 'CD-002').findAll('button').find(item => item.text() === '详情')!.trigger('click'); await flushPromises()
expect(api.photo).toHaveBeenLastCalledWith('CD-002', '9007199254740996', 'CUSTOMER-2')
resolve(new Blob(['old'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledOnce(); expect(dialog.props('title')).toBe('废弃单详情 · CD-002')
})
it('ignores a detail response after unmounting before it can start photo downloads', async () => {
let resolve!: (value: CargoDiscardDetail) => void
api.detail.mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = await open(); wrapper.unmount()
const result = detail(); result.photos = [photo()]; resolve(result); await flushPromises()
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps empty evidence explicit and never infers all inventory from missing carton marks', async () => {
const result = detail(); result.items[0]!.cartonMarks = []; result.histories = []; result.summary.status = null
api.list.mockResolvedValueOnce({ records: [summary({ status: null })], total: 1 }); api.detail.mockResolvedValueOnce(result)
const { wrapper, dialog } = await open()
expect(wrapper.text()).not.toContain('undefined'); expect(dialog.text()).not.toContain('undefined')
expect(dialog.text()).toContain('暂无已提交的废弃现场照片'); expect(dialog.text()).toContain('暂无状态流转记录')
expect(dialog.text()).not.toContain('全部库存'); expect(dialog.text()).not.toContain('历史记录')
expect(dialog.findAllComponents(LineClampText).some(item => item.props('content') === '')).toBe(true)
expect(dialog.text()).not.toContain('9007199254740995')
})
it('blocks photo requests with a missing customer scope instead of sending an undefined identifier', async () => {
const result = detail(); result.summary.customerId = null; result.photos = [photo()]; api.detail.mockResolvedValueOnce(result)
const { dialog } = await open(); expect(dialog.text()).toContain('照片缺少客户范围')
expect(api.photo).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('keeps customer and list failures independent and retries each original query', async () => {
api.customers.mockRejectedValueOnce(new ApiResponseError('客户失败', 'A0503', 503, null, 'discard-customer-request'))
api.list.mockRejectedValueOnce(new ApiResponseError('列表失败', 'A0503', 503, null, 'discard-list-request'))
const wrapper = await page(); expect(wrapper.text()).toContain('discard-customer-request'); expect(wrapper.text()).toContain('discard-list-request')
await retry(errorState(wrapper, '废弃单列表加载失败')); expect(row(wrapper).text()).toContain('CD-001')
await retry(errorState(wrapper, '客户列表加载失败'))
expect(api.customers).toHaveBeenLastCalledWith({ page: 1, size: 200, status: 'ACTIVE' })
expect(api.list).toHaveBeenLastCalledWith({ page: 1, size: 10, customerId: undefined, discardNo: undefined, inboundCode: undefined, status: undefined })
})
it('ignores a stale list response after a newer query has completed', async () => {
let resolve!: (value: { records: CargoDiscardSummary[]; total: number }) => void
api.list.mockReturnValueOnce(new Promise(done => { resolve = done }))
const wrapper = await page(); api.list.mockResolvedValueOnce({ records: [summary({ discardNo: 'CD-LATEST' })], total: 1 })
wrapper.findComponent(QueryToolbar).vm.$emit('search', { customerId: '', discardNo: 'CD-LATEST', inboundCode: '', status: '' }); await flushPromises()
resolve({ records: [summary({ discardNo: 'CD-OLD' })], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('CD-LATEST'); expect(wrapper.text()).not.toContain('CD-OLD')
})
})