146 lines
16 KiB
TypeScript
146 lines
16 KiB
TypeScript
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()
|
||
})
|