260 lines
19 KiB
TypeScript
260 lines
19 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
|
|
import { defineComponent, h, KeepAlive, ref } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import * as api from '../api/drivers'
|
|
import type { DriverDetail } from '../driverTypes'
|
|
import DriversView from './DriversView.vue'
|
|
import DriverDetailView from './DriverDetailView.vue'
|
|
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
|
|
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
|
|
|
|
const security = vi.hoisted(() => ({ allowWrite: true }))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) }))
|
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), useRoute: () => ({ params: { driverId: '9223372036854775701' } }) }))
|
|
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } }))
|
|
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() }))
|
|
|
|
const driver: DriverDetail = {
|
|
id: '9223372036854775701', driverNo: 'DR-TEST', details: { name: '测试司机', phone: '123', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: { warehouseId: '92', vehicleModels: '53尺拖车', languagesAndArea: '中文 / 洛杉矶', medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } },
|
|
active: true, reviewStatus: 'PENDING', licenseFileId: '9223372036854775801', recordVersion: 2, updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: false, expiringSoon: true, reason: 'PENDING_REVIEW', licenseExpiresOn: '2026-10-22' }, licenseState: 'EXPIRING', warehouseCode: 'WH-US', warehouseName: '洛杉矶仓',
|
|
}
|
|
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
|
const button = { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }
|
|
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm', 'update:modelValue'], template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="confirm" @click="$emit(\'confirm\')">保存</button></div>' }
|
|
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' }
|
|
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.id || row.recordVersion"><span>{{row.driverNo}}</span><slot name="actions" :row="row"/><slot name="file" :row="row"/></div></div>' }
|
|
const stubs = { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, RowActions: true, StatusBadge: true, LoadErrorState: true, AppDialog: dialog, DriverEditorDialog: true, DriverLicensePreview: true, ElButton: button, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElOption: true, ElDatePicker: { ...input, name: 'ElDatePicker' } }
|
|
const wrappers: VueWrapper[] = []
|
|
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
|
|
const wrapper = shallowMount(component, { props, global: { stubs: { ...stubs, DriverEditorDialog: component === DriverEditorDialog ? false : true, DriverLicensePreview: component === DriverLicensePreview ? false : true }, directives: { loading: () => undefined } } })
|
|
wrappers.push(wrapper)
|
|
return wrapper
|
|
}
|
|
function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks(); security.allowWrite = true
|
|
vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } })
|
|
vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver))
|
|
vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }])
|
|
vi.mocked(api.listDriverHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
|
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn().mockReturnValue('blob:test-license'), revokeObjectURL: vi.fn() }))
|
|
})
|
|
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() })
|
|
|
|
describe('driver page workflows', () => {
|
|
it('refreshes a cached list once on return and retains applied filters and pagination', async () => {
|
|
const showingList = ref(true)
|
|
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) })
|
|
const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } })
|
|
wrappers.push(wrapper)
|
|
await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(1)
|
|
const list = wrapper.findComponent(DriversView)
|
|
const toolbar = list.findComponent({ name: 'QueryToolbar' })
|
|
toolbar.vm.$emit('update:modelValue', { keyword: 'DR-TEST', licenseState: 'EXPIRING' })
|
|
toolbar.vm.$emit('search'); await flushPromises()
|
|
list.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(3)
|
|
showingList.value = false; await flushPromises()
|
|
const refreshed = { ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' as const }
|
|
vi.mocked(api.listDrivers).mockResolvedValueOnce({ records: [refreshed], page: 2, size: 20, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 1, expired: 0 } })
|
|
showingList.value = true; await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(4)
|
|
expect(api.listDrivers).toHaveBeenLastCalledWith({ keyword: 'DR-TEST', licenseState: 'EXPIRING', region: undefined, ownership: undefined, active: undefined, reviewStatus: undefined, page: 2, size: 20 })
|
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([refreshed])
|
|
expect(wrapper.text()).toContain('司机总数1')
|
|
expect(api.getDriver).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('ignores a list response that arrives while its cached page is deactivated', async () => {
|
|
let resolveList!: (value: Awaited<ReturnType<typeof api.listDrivers>>) => void
|
|
vi.mocked(api.listDrivers).mockReturnValueOnce(new Promise(resolve => { resolveList = resolve }))
|
|
const showingList = ref(true)
|
|
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => showingList.value ? h(DriversView) : h('div', '司机详情') }) })
|
|
const wrapper = shallowMount(host, { global: { stubs: { ...stubs, KeepAlive: false, DriversView: false }, directives: { loading: () => undefined } } })
|
|
wrappers.push(wrapper)
|
|
await flushPromises()
|
|
const list = wrapper.findComponent(DriversView)
|
|
showingList.value = false; await flushPromises()
|
|
resolveList({ records: [driver], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } }); await flushPromises()
|
|
expect(list.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
|
|
showingList.value = true; await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(2)
|
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([driver])
|
|
})
|
|
|
|
it('loads one paged request with server summary and does not request each driver or file', async () => {
|
|
const wrapper = mount(DriversView)
|
|
await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.text()).toContain('司机总数24')
|
|
expect(api.getDriver).not.toHaveBeenCalled()
|
|
expect(api.downloadDriverLicense).not.toHaveBeenCalled()
|
|
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
|
|
await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(2)
|
|
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
|
await findButton(wrapper, '即将到期').trigger('click')
|
|
await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(3)
|
|
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, licenseState: 'EXPIRING' }))
|
|
})
|
|
|
|
it('shows a retryable error instead of stale summary after list failure', async () => {
|
|
vi.mocked(api.listDrivers).mockRejectedValueOnce(new Error('服务不可用'))
|
|
const wrapper = mount(DriversView)
|
|
await flushPromises()
|
|
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
|
expect(error.exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('司机总数—')
|
|
error.vm.$emit('retry'); await flushPromises()
|
|
expect(api.listDrivers).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not expose write actions to a read-only user', async () => {
|
|
security.allowWrite = false
|
|
const list = mount(DriversView), detail = mount(DriverDetailView)
|
|
await flushPromises()
|
|
expect(list.text()).not.toContain('新增司机')
|
|
expect(detail.text()).not.toContain('审核通过')
|
|
expect(detail.text()).not.toContain('上传 / 替换驾驶证')
|
|
expect(detail.text()).not.toContain('编辑档案')
|
|
})
|
|
|
|
it('loads detail once, requests history only on demand, and requires a rejection reason', async () => {
|
|
const wrapper = mount(DriverDetailView)
|
|
await flushPromises()
|
|
expect(api.getDriver).toHaveBeenCalledTimes(1)
|
|
expect(api.listDriverHistory).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('洛杉矶仓')
|
|
expect(wrapper.text()).not.toContain(driver.id)
|
|
await findButton(wrapper, '打回').trigger('click')
|
|
expect(api.reviewDriver).not.toHaveBeenCalled()
|
|
expect(ElMessage.warning).toHaveBeenCalledWith('请填写打回原因')
|
|
await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
|
|
expect(api.listDriverHistory).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('submits approval with current version and uses returned state without reloading detail', async () => {
|
|
vi.mocked(api.reviewDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' })
|
|
const wrapper = mount(DriverDetailView)
|
|
await flushPromises()
|
|
await findButton(wrapper, '审核通过').trigger('click'); await flushPromises()
|
|
expect(api.reviewDriver).toHaveBeenCalledWith(driver.id, 2, 'APPROVED', null)
|
|
expect(api.getDriver).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.findAll('button').some(value => value.text() === '审核通过')).toBe(false)
|
|
})
|
|
|
|
it('keeps the editor and version after a failed write', async () => {
|
|
vi.mocked(api.updateDriver).mockRejectedValue(new ApiResponseError('版本已变化,请刷新', 'A0409', 200, null, 'req-driver-conflict'))
|
|
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
|
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.objectContaining({ name: driver.details.name }))
|
|
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('版本已变化')
|
|
})
|
|
|
|
it('invalidates an old task preview after the planned end changes without inventing a new server qualification', async () => {
|
|
vi.mocked(api.getDriverQualification).mockResolvedValue({ ...driver.qualification, canDispatch: true, reason: 'ELIGIBLE' })
|
|
const wrapper = mount(DriverDetailView)
|
|
await flushPromises()
|
|
const picker = wrapper.findComponent({ name: 'ElDatePicker' })
|
|
picker.vm.$emit('update:modelValue', new Date('2026-10-20T12:00:00Z'))
|
|
picker.vm.$emit('change'); await flushPromises()
|
|
await findButton(wrapper, '校验本次任务资格').trigger('click'); await flushPromises()
|
|
expect(api.getDriverQualification).toHaveBeenCalledWith(driver.id, '2026-10-20T12:00:00.000Z')
|
|
expect(wrapper.text()).toContain('本次任务可派车')
|
|
picker.vm.$emit('update:modelValue', new Date('2026-11-20T12:00:00Z'))
|
|
picker.vm.$emit('change'); await flushPromises()
|
|
expect(wrapper.text()).not.toContain('本次任务可派车')
|
|
expect(wrapper.text()).toContain('请重新校验')
|
|
expect(api.getDriverQualification).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not create a second draft when attachment upload fails after saving', async () => {
|
|
vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 })
|
|
vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 })
|
|
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver: null })
|
|
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
|
|
inputs[0]!.vm.$emit('update:modelValue', '新司机')
|
|
inputs[1]!.vm.$emit('update:modelValue', '123')
|
|
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
|
|
selects.at(-1)!.vm.$emit('update:modelValue', 'UTC')
|
|
const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' })
|
|
const picker = wrapper.get('input[type="file"]')
|
|
Object.defineProperty(picker.element, 'files', { value: [file] })
|
|
await picker.trigger('change')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createDriver).toHaveBeenCalledTimes(1)
|
|
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('档案已保存')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.createDriver).toHaveBeenCalledTimes(1)
|
|
expect(api.updateDriver).not.toHaveBeenCalled()
|
|
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(2)
|
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
|
|
})
|
|
|
|
it('keeps an old save outside the newly opened driver session and uploads only that driver file', async () => {
|
|
let finishOld!: (value: DriverDetail) => void
|
|
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
|
|
const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
|
|
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
|
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything())
|
|
await wrapper.setProps({ modelValue: false }); await flushPromises()
|
|
await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises()
|
|
const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' })
|
|
const picker = wrapper.get('input[type="file"]')
|
|
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
|
|
await picker.trigger('change')
|
|
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
|
|
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
|
|
expect(wrapper.emitted('saved')).toBeUndefined()
|
|
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
|
|
expect(wrapper.findAllComponents({ name: 'ElInput' })[0]!.props('modelValue')).toBe('另一司机')
|
|
vi.mocked(api.updateDriver).mockResolvedValue({ ...other, recordVersion: 3 })
|
|
vi.mocked(api.uploadDriverLicense).mockResolvedValue({ ...other, recordVersion: 4 })
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
expect(api.updateDriver).toHaveBeenLastCalledWith(other.id, 2, expect.objectContaining({ name: '另一司机' }))
|
|
expect(api.uploadDriverLicense).toHaveBeenCalledExactlyOnceWith(other.id, 3, file)
|
|
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(expect.objectContaining({ id: other.id }))
|
|
})
|
|
|
|
it('does not continue a pending save with an upload after the editor is unmounted', async () => {
|
|
let finishOld!: (value: DriverDetail) => void
|
|
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
|
|
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
|
|
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
const picker = wrapper.get('input[type="file"]')
|
|
Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] })
|
|
await picker.trigger('change')
|
|
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
|
wrapper.unmount()
|
|
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
|
|
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
|
|
expect(ElMessage.success).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('releases preview blobs when closed and ignores a download completed after close', async () => {
|
|
vi.mocked(api.downloadDriverLicense).mockResolvedValueOnce(new Blob(['%PDF'], { type: 'application/pdf' }))
|
|
const wrapper = mount(DriverLicensePreview, { modelValue: false, driverId: driver.id, fileId: driver.licenseFileId, driverName: driver.details.name })
|
|
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
expect(wrapper.find('iframe').exists()).toBe(true)
|
|
await wrapper.setProps({ modelValue: false })
|
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-license')
|
|
let complete!: (value: Blob) => void
|
|
vi.mocked(api.downloadDriverLicense).mockReturnValueOnce(new Promise(resolve => { complete = resolve }))
|
|
await wrapper.setProps({ modelValue: true })
|
|
await wrapper.setProps({ modelValue: false })
|
|
complete(new Blob(['%PDF'], { type: 'application/pdf' })); await flushPromises()
|
|
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|