Files
admin-web/src/modules/port-schedules/components/ContainerPortSchedulePanel.test.ts
T

103 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})