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

146 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import type { DispatchEvidenceFile } from '../dispatchContracts'
import { downloadDriverLicense } from '@/modules/overseas-master-data'
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() }))
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => {
vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises()
expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33')
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301')
expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33')
expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount()
})
async function selectFile(wrapper: ReturnType<typeof mount>, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
it('renders authenticated photos above their caption and releases thumbnails on unmount', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, readonly: true, label: '车辆图片' } }); await flushPromises()
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
const figure = wrapper.get('figure')
expect(figure.get('img').attributes('src')).toBe('blob:authenticated')
expect(wrapper.get('.dispatch-evidence').attributes('data-tone')).toBeUndefined()
expect(figure.get('.evidence-photo-name').text()).toBe('本车证据.png')
expect(figure.get('figcaption').text()).toBe('车辆图片')
expect(figure.element.lastElementChild?.tagName).toBe('FIGCAPTION')
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
})
it('does not render a late thumbnail from the previous dispatch or an invalid image response', async () => {
let resolve!: (value: Blob) => void
vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true } }); await flushPromises()
await wrapper.setProps({ dispatchNo: 'PC/2', modelValue: [] })
resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled()
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['bad'], { type: 'text/html' }))
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false)
wrapper.unmount()
})
it('uses the dialog body for the image and keeps loading until the browser decodes it', async () => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises()
await button(wrapper, '预览').trigger('click'); await flushPromises()
const dialog = wrapper.get('[role="dialog"]')
expect(dialog.find('table').exists()).toBe(false)
expect(dialog.get('.evidence-preview').attributes('aria-busy')).toBe('true')
const image = dialog.get('img')
Object.defineProperties(image.element, { naturalWidth: { value: 1200 }, naturalHeight: { value: 800 } })
await image.trigger('load')
expect(dialog.get('.evidence-preview').attributes('aria-busy')).toBe('false')
expect(image.attributes('src')).toBe('blob:authenticated')
expect(dialog.text()).not.toContain('图片无法预览')
wrapper.unmount()
})
it.each(['single-pixel', 'decode-error'])('explains %s images inside the preview and retries the owned content', async failure => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises()
await button(wrapper, '预览').trigger('click'); await flushPromises()
const image = wrapper.get('[role="dialog"] img')
if (failure === 'single-pixel') {
Object.defineProperties(image.element, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } })
await image.trigger('load')
} else await image.trigger('error')
expect(wrapper.get('[role="dialog"]').text()).toContain(failure === 'single-pixel' ? '1 × 1 像素' : '图片无法解码或加载')
expect(wrapper.get('.evidence-preview').attributes('aria-busy')).toBe('false')
expect(wrapper.find('[role="dialog"] img').exists()).toBe(false)
await button(wrapper, '重新预览').trigger('click'); await flushPromises()
expect(api.getDispatchEvidenceContent).toHaveBeenCalledTimes(2)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
expect(wrapper.get('[role="dialog"] img').attributes('src')).toBe('blob:authenticated')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it('keeps previously provided references read-only and only submits newly uploaded audit files', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: [], referenceIds: ['301'], kind: 'DRIVER_PHOTO', label: '司机照片' } }); await flushPromises()
expect(wrapper.findAll('tbody button').map(item => item.text())).toEqual(['预览'])
await selectFile(wrapper, new File(['pdf'], 'photo.pdf', { type: 'application/pdf' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER_PHOTO', expect.any(File))
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']])
wrapper.unmount()
})
it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
const actual = await vi.importActual<typeof import('../api/dispatches')>('../api/dispatches')
const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
const get = vi.spyOn(http, 'get').mockResolvedValue(envelope('9007199254740993'))
const post = vi.spyOn(http, 'post').mockResolvedValue(envelope('9007199254740994'))
vi.mocked(api.getDispatchEvidence).mockImplementation(actual.getDispatchEvidence); vi.mocked(api.uploadDispatchEvidence).mockImplementation(actual.uploadDispatchEvidence)
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: ['9007199254740993'] } })
try {
await flushPromises(); expect(wrapper.text()).toContain('本车证据.png')
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['9007199254740994']]); expect(post).toHaveBeenCalledOnce()
} finally { wrapper.unmount(); get.mockRestore(); post.mockRestore() }
})
it('reads owned metadata without displaying IDs or downloading content until explicitly previewed, including read-only mode', async () => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, readonly: true } }); await flushPromises()
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('本车证据.png'); expect(wrapper.text()).not.toContain('301'); expect(wrapper.find('input').exists()).toBe(false)
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
await button(wrapper, '关闭预览').trigger('click'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); wrapper.unmount()
})
it('retains original references when metadata or replacement upload fails and preserves request diagnostics', async () => {
vi.mocked(api.getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('Forbidden', 'A0403', 403, undefined, 'request-1', 'trace-1'))
const failed = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(failed.emitted('update:modelValue')).toBeUndefined(); expect(failed.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-1', traceId: 'trace-1' }); failed.unmount()
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('unknown upload'))
await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' })); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('原附件引用保留'); wrapper.unmount()
})
it('replaces the selected reference only after a valid successful upload and rejects unsupported types locally', async () => {
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await selectFile(wrapper, new File(['exe'], 'file.exe', { type: 'application/octet-stream' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('10MiB以内')
await button(wrapper, '替换').trigger('click'); const file = new File(['png'], 'new.png', { type: 'image/png' }); await selectFile(wrapper, file)
expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', file); expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); expect(wrapper.emitted('busy')?.slice(-2)).toEqual([[true], [false]]); wrapper.unmount()
})
it('ignores a late upload after switching dispatches instead of binding the file to the new vehicle', async () => {
let resolve!: (value: DispatchEvidenceFile) => void; vi.mocked(api.uploadDispatchEvidence).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
await wrapper.setProps({ dispatchNo: 'PC/2' }); resolve(metadata('302')); await flushPromises(); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('does not turn business-error JSON into an image and retains diagnostic evidence', async () => {
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce({ type: 'application/json', size: 100, text: async () => JSON.stringify({ code: 'A0403', message: 'Forbidden', requestId: 'request-content', traceId: 'trace-content' }) } as Blob)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false); expect(wrapper.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-content', traceId: 'trace-content' }); wrapper.unmount()
})
it('ignores content arriving after preview close and cleans up object URLs on unmount', async () => {
let resolve!: (value: Blob) => void; vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await button(wrapper, '关闭预览').trigger('click'); resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled()
await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
})
it('opens PDF in a user-requested window using owned blob content and never an anonymous URL', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
const popup = { location: { href: '' }, close: vi.fn() }; const open = vi.spyOn(window, 'open').mockReturnValueOnce(popup as unknown as Window)
const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(open).toHaveBeenCalledWith('', '_blank'); expect(popup.location.href).toBe('blob:authenticated'); expect(wrapper.find('img').exists()).toBe(false); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); open.mockRestore()
})
it('rejects mismatched file identity and a blocked PDF popup without fetching content', async () => {
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('wrong-file'))
const mismatch = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(button(mismatch, '预览').attributes('disabled')).toBeDefined(); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); mismatch.unmount()
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
})