103 lines
10 KiB
TypeScript
103 lines
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { flushPromises, mount } from '@vue/test-utils'
|
||
import ElementPlus from 'element-plus'
|
||
import Panel from './ContainerPortSchedulePanel.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import { getPortSchedule, createPortSchedule, updatePortSchedule } from '../api/platformPortSchedules'
|
||
import { ApiResponseError } from '@/shared/api/http'
|
||
const can = vi.hoisted(() => vi.fn(() => true))
|
||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn(), createPortSchedule: vi.fn(), updatePortSchedule: vi.fn() }))
|
||
const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oceanBookingId: 'B1', currentPortScheduleId: 'S1', billOfLadingNo: 'BL1', cabinetNo: 'D1', containerNo: 'TEST', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', estimatedSailingAt: '2026-10-04T01:00:00', estimatedPortArrivalAt: '2026-10-20T01:00:00' }
|
||
const original = { id: 'S1', scheduleNo: 'PS1', version: 5, customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: container.estimatedSailingAt, etaAt: container.estimatedPortArrivalAt, observedAt: '2026-10-01T00:00:00', expiresAt: '2026-10-10T00:00:00', portCutoffAt: '2026-10-03T00:00:00', remark: '原备注' }
|
||
const result = () => ({ portSchedule: { schedule: { ...original }, expired: false }, histories: [] })
|
||
interface VM { begin(): Promise<void>; save(): Promise<void>; form: Record<string, string>; error: string; open: boolean; schedule: ReturnType<typeof result> | null }
|
||
function setup(value = container, realDialog = false) {
|
||
const wrapper = mount(Panel, { props: { container: value }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : { AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' } } } })
|
||
return { wrapper, vm: wrapper.vm as unknown as VM }
|
||
}
|
||
describe('original container schedule', () => {
|
||
beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true); vi.mocked(getPortSchedule).mockResolvedValue(result()); vi.mocked(updatePortSchedule).mockResolvedValue(result()); vi.mocked(createPortSchedule).mockResolvedValue(result()) })
|
||
it('loads only the server selected ID, preserves original fields, and unlocks editing and uses a fresh key after failure', async () => {
|
||
const { wrapper, vm } = setup(container, true); await vm.begin()
|
||
expect(getPortSchedule).toHaveBeenCalledExactlyOnceWith('S1')
|
||
vm.form.vesselName = 'NEW'; vm.form.voyageNo = 'V2'; vm.form.reason = '原船满载改配'
|
||
vi.mocked(updatePortSchedule).mockRejectedValueOnce(new Error('timeout'))
|
||
await vm.save(); expect(vm.form.vesselName).toBe('NEW'); expect(vm.error).toContain('timeout')
|
||
await flushPromises()
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.props('title')).toBe('原柜船期与修改历史 · D1')
|
||
expect(dialog.getComponent({ name: 'ElForm' }).props('disabled')).toBe(false)
|
||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||
expect(dialog.get<HTMLInputElement>('input[placeholder="请输入船名"]').element.value).toBe('NEW')
|
||
await vm.save()
|
||
expect(vi.mocked(updatePortSchedule).mock.calls[1]![1]).toEqual(vi.mocked(updatePortSchedule).mock.calls[0]![1]); expect(vi.mocked(updatePortSchedule).mock.calls[1]![2]).not.toBe(vi.mocked(updatePortSchedule).mock.calls[0]![2])
|
||
expect(updatePortSchedule).toHaveBeenLastCalledWith('S1', { expectedVersion: 5, reason: '原船满载改配', data: expect.objectContaining({ vesselName: 'NEW', voyageNo: 'V2', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', observedAt: original.observedAt, expiresAt: original.expiresAt, portCutoffAt: original.portCutoffAt, remark: '原备注', containerBookingId: 'C1' }) }, expect.any(String))
|
||
expect(createPortSchedule).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||
})
|
||
it('does not edit a shared booking schedule or another container even when ID is supplied', async () => {
|
||
vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), portSchedule: { schedule: { ...original, containerBookingId: 'C2' }, expired: false } })
|
||
const { vm, wrapper } = setup(); await vm.begin(); vm.form.reason = '改配'; await vm.save()
|
||
expect(vm.error).toContain('不能修改共享或其他柜船期'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
|
||
})
|
||
it('list-only account can see four-field history but cannot write', async () => {
|
||
can.mockImplementation((...args: unknown[]) => args[0] === 'platform:firstleg:port-schedule:list')
|
||
vi.mocked(getPortSchedule).mockResolvedValue({ ...result(), histories: [{ id: 'H1', action: 'UPDATE', reason: '改配', operatorName: '操作员', createdAt: '2026-10-03T00:00:00', beforeSnapshot: JSON.stringify(original), afterSnapshot: JSON.stringify({ ...original, vesselName: 'NEW' }) }] })
|
||
const { wrapper, vm } = setup(container, true); await vm.begin(); await flushPromises(); await vm.save()
|
||
expect(wrapper.text()).toContain('NEW'); expect(wrapper.text()).toContain('操作员'); expect(wrapper.text()).toContain('改配')
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.get('table[aria-label="当前船期只读资料"]').text()).toContain('OLD')
|
||
expect(dialog.findAll('input, textarea')).toHaveLength(0)
|
||
expect(dialog.props('showConfirm')).toBe(false)
|
||
expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
|
||
})
|
||
it('creation needs explicit source and dates and locks original ownership', async () => {
|
||
const { wrapper, vm } = setup({ ...container, currentPortScheduleId: '' }, true); await vm.begin(); vm.form.reason = '首次登记'
|
||
await flushPromises()
|
||
const dialog = wrapper.getComponent(AppDialog)
|
||
expect(dialog.props('title')).toBe('登记原柜船期 · D1')
|
||
expect(dialog.findAllComponents({ name: 'ElFormItem' })).toHaveLength(12)
|
||
expect(dialog.get('table[aria-label="原柜船期归属"]').text()).toContain('平台订舱')
|
||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||
expect(vm.form.reason).toBe('首次登记')
|
||
await vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('来源')
|
||
Object.assign(vm.form, { sourceName: '平台人工确认', sourceRef: '船司邮件号', observedAt: '2026-10-03T00:00:00', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' })
|
||
await vm.save(); expect(getPortSchedule).not.toHaveBeenCalled()
|
||
expect(createPortSchedule).toHaveBeenCalledWith(expect.objectContaining({ customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'MANUAL', sourceRef: '船司邮件号', expiresAt: '2026-10-05T00:00:00', portCutoffAt: '2026-10-03T01:00:00' }), expect.any(String), '首次登记')
|
||
wrapper.unmount()
|
||
})
|
||
it('missing original ownership blocks initialization and late read cannot populate another container', async () => {
|
||
const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).not.toContain('不能维护船期'); expect(first.wrapper.find('button').attributes('disabled')).toBeDefined(); first.wrapper.unmount()
|
||
let resolve!: (value: ReturnType<typeof result>) => void
|
||
vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done }))
|
||
const { wrapper, vm } = setup(); const pending = vm.begin()
|
||
await wrapper.setProps({ container: { ...container, id: 'C2', currentPortScheduleId: 'S2' } }); resolve(result()); await pending
|
||
expect(vm.open).toBe(false); expect(vm.schedule).toBe(null); wrapper.unmount()
|
||
})
|
||
it('retains edited input and original version on conflict and does not silently refresh it', async () => {
|
||
const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'CONFLICT'; vm.form.reason = '改配'
|
||
vi.mocked(updatePortSchedule).mockRejectedValue(new ApiResponseError('版本冲突', 'A0409'))
|
||
await vm.save(); expect(vm.form.vesselName).toBe('CONFLICT'); expect(vm.error).toContain('输入已保留')
|
||
expect(getPortSchedule).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
|
||
})
|
||
it('separates create and edit permissions and rejects missing reason or impossible dates', async () => {
|
||
can.mockImplementation((...args: unknown[]) => args[0] !== 'platform:firstleg:port-schedule:create')
|
||
const first = setup({ ...container, currentPortScheduleId: '' }); await first.vm.begin(); await first.vm.save(); expect(createPortSchedule).not.toHaveBeenCalled(); first.wrapper.unmount()
|
||
const { wrapper, vm } = setup(); await vm.begin(); await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled()
|
||
vm.form.reason = '改配'; vm.form.etaAt = '2026-01-01T00:00:00'; await vm.save(); expect(vm.error).toContain('不能早于'); expect(updatePortSchedule).not.toHaveBeenCalled(); wrapper.unmount()
|
||
})
|
||
it('ignores late save after closing and renders no internal IDs in snapshots', async () => {
|
||
let resolve!: (value: ReturnType<typeof result>) => void
|
||
vi.mocked(updatePortSchedule).mockReturnValue(new Promise(done => { resolve = done }))
|
||
const { wrapper, vm } = setup(); await vm.begin(); vm.form.vesselName = 'NEW'; vm.form.reason = '改配'; const saved = vm.save()
|
||
vm.open = false; await flushPromises(); resolve(result()); await saved
|
||
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
|
||
})
|
||
it('does not send another update when transport values have not changed', async () => {
|
||
const { wrapper, vm } = setup(); await vm.begin(); vm.form.reason = '重复点击'
|
||
await vm.save(); expect(updatePortSchedule).not.toHaveBeenCalled(); expect(vm.error).toContain('未变更')
|
||
expect(wrapper.text()).not.toContain('(UTC)'); wrapper.unmount()
|
||
})
|
||
})
|