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, getResourcePhoto, downloadResourcePhoto } from '@/modules/overseas-master-data'
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: 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: '' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '' } } } }
const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('上传失败'))
await selectFile(wrapper, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
wrapper.unmount()
})
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, 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('../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()
})
it('accepts a PDF driver license in the photo card without requiring another image', async () => {
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302', 'application/pdf'))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: [] } })
await flushPromises(); await selectFile(wrapper, new File(['pdf'], 'license.pdf', { type: 'application/pdf' }))
expect(api.uploadDispatchEvidence).toHaveBeenCalledOnce()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); wrapper.unmount()
})
it('previews a master photo without submitting its foreign file ID as dispatch evidence', async () => {
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '1', photo: metadata('81') })
vi.mocked(downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
await flushPromises()
expect(wrapper.text()).toContain('已带入档案照片')
expect(api.getDispatchEvidence).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('available')?.at(-1)).toEqual([true])
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
expect(wrapper.text()).not.toContain('已带入档案照片')
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
expect(getResourcePhoto).toHaveBeenCalledOnce(); wrapper.unmount()
})
it('ignores late profile photo responses after changing the selected resource', async () => {
let finish!: (value: Awaited>) => void
vi.mocked(getResourcePhoto).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
.mockResolvedValueOnce({ recordVersion: '0', photo: null })
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
await wrapper.setProps({ masterResource: { kind: 'VEHICLE', id: '8' } }); await flushPromises()
finish({ recordVersion: '1', photo: metadata('81') }); await flushPromises()
expect(downloadResourcePhoto).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('已带入档案照片')
expect(wrapper.emitted('available')?.at(-1)).toEqual([false]); wrapper.unmount()
})