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() })