fix(dispatch): release failed request state and return rejected reviews

This commit is contained in:
2026-10-22 10:10:32 +08:00
parent ef85ed30e4
commit 090431b409
18 changed files with 373 additions and 536 deletions
@@ -3,154 +3,65 @@ import { ApiResponseError } from '@/shared/api/http'
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
import { command } from './dispatchTestFixtures'
beforeEach(() => sessionStorage.clear())
it('rejects a loading result that has not completed loading', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'LOADING' }), read, vi.fn())).rejects.toThrow('装车完成')
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value?.result).toBeNull()
expect(recovery.pending.value?.body.idempotencyKey).toBe('key')
it.each([new Error('network unknown'), new ApiResponseError('server failure','S0500',200), new ApiResponseError('gateway timeout','HTTP_504',504), new ApiResponseError('version changed','A0409',200)])('releases failed $message and permits a new request with edited input', async failure => {
const owner=crypto.randomUUID(), state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first',remark:'original'})
await expect(state.execute(vi.fn().mockRejectedValue(failure),vi.fn())).rejects.toBe(failure)
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
const reopened=useDispatchCommandRecovery(()=>owner)
reopened.begin('PC/1','EDIT',{recordVersion:2,idempotencyKey:'second',remark:'edited'})
const send=vi.fn().mockResolvedValue({...command('EDIT','second'),recordVersion:3})
await reopened.execute(send,vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:3}))
expect(send.mock.calls[0]![0].body).toEqual({recordVersion:2,idempotencyKey:'second',remark:'edited'})
expect(reopened.pending.value).toBeNull()
})
it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('LOADING_COMPLETE'))
const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
await recovery.execute(send, read, find)
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
expect(recovery.pending.value).toBeNull()
it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('clears saved failed requests without restoring them',saved=>{
const owner=crypto.randomUUID()
sessionStorage.setItem(`dispatch-command:${owner}`,saved)
const state=useDispatchCommandRecovery(()=>owner)
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('keeps a proven result across GET failure and does not send the command a second time', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
await reopened.execute(send, read, find)
expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
expect(reopened.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
it('prevents double clicks only during the active request',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first'})
let finish!:(result:ReturnType<typeof command>)=>void
const send=vi.fn().mockReturnValue(new Promise(done=>{finish=done}))
const read=vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:1})
const running=state.execute(send,read),another=useDispatchCommandRecovery(()=>owner)
expect(another.busy.value).toBe(true)
expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).toThrow('正在提交')
await another.execute(send,read);expect(send).toHaveBeenCalledTimes(1)
finish(command('EDIT','first'));await running
expect(another.pending.value).toBeNull();expect(another.busy.value).toBe(false)
expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).not.toThrow()
})
it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
expect(second.pending.value?.dispatchNo).toBe('PC/1')
expect(() => second.begin('', 'CREATE', { idempotencyKey: 'new' })).toThrow('原请求')
it('unlocks even when a successful write is followed by a failed detail read',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT')),vi.fn().mockRejectedValue(new Error('read failed')))).rejects.toThrow('read failed')
expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})
it('does not discard an earlier unknown write on a later business rejection', async () => {
const recovery = useDispatchCommandRecovery(() => crypto.randomUUID())
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })).toThrow('账号')
const owner = crypto.randomUUID(), stable = useDispatchCommandRecovery(() => owner)
stable.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
expect(find).toHaveBeenCalledWith('PC/1', 'key')
expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key' })
expect(stable.pending.value?.rejected).toBe(false)
const saved = JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!)
expect(saved).toMatchObject({ attempted: true, rejected: false, result: null, body: { recordVersion: 0, idempotencyKey: 'key' } })
expect(useDispatchCommandRecovery(() => owner).pending.value).toEqual(saved)
it('keeps loading-stage and response identity checks without retaining failed requests',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner),read=vi.fn()
state.begin('PC/1','LOADING_COMPLETE',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue({...command('LOADING_COMPLETE'),stage:'LOADING'}),read)).rejects.toThrow('装车完成')
expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT','different')),read)).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
})
it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const read = vi.fn()
await expect(recovery.execute(vi.fn().mockResolvedValue(command('LOADING_COMPLETE', 'different')), read, vi.fn())).rejects.toThrow('不一致')
expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
it('preserves large version precision and unlocks after a mismatched detail',async()=>{
const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:'9007199254740992',idempotencyKey:'key'})
await expect(state.execute(vi.fn().mockResolvedValue({...command('EDIT'),recordVersion:'9007199254740993'}),vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:'9007199254740992'}))).rejects.toThrow('详情版本早于')
expect(state.pending.value).toBeNull()
})
it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), recordVersion: '9007199254740993' }), find = vi.fn()
await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
expect(recovery.pending.value?.result).not.toBeNull()
await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
})
it('treats an internal server error as unknown instead of authorizing a new command', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
})
it.each(['400', '401', '403', '404', '409', '410', '422'].flatMap(code => [code, `A0${code}`, `HTTP_${code}`]))(
'clears the first definite rejection %s from memory and storage and preserves its original error', async code => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'rejected-key' })
const rejection = new ApiResponseError('明确拒绝', code)
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn(), find = vi.fn()
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(send).toHaveBeenCalledTimes(1)
expect(read).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled()
expect(recovery.pending.value).toBeNull(); expect(recovery.busy.value).toBe(false)
expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
const reopened = useDispatchCommandRecovery(() => owner)
expect(reopened.pending.value).toBeNull()
expect(() => reopened.begin('PC/1', 'EDIT', { recordVersion: 2, idempotencyKey: 'fresh-key' })).not.toThrow()
},
)
it('cleans an explicitly rejected legacy browser record when its scope is restored', () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
sessionStorage.setItem(key, JSON.stringify({ dispatchNo: 'PC/1', action: 'SUBMIT_REVIEW',
body: { recordVersion: 0, idempotencyKey: 'terminal-key' }, attempted: true, rejected: true, result: null }))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 1, idempotencyKey: 'new-key' })).not.toThrow()
})
it('cleans a terminal rejected record already shared by dialogs in the same scope', () => {
const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner)
first.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'terminal-key' })
first.pending.value!.attempted = true; first.pending.value!.rejected = true
sessionStorage.setItem(`dispatch-command:${owner}`, JSON.stringify(first.pending.value))
const second = useDispatchCommandRecovery(() => owner)
expect(second.pending).toBe(first.pending)
expect(first.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
it('retains a restored unknown record without explicit rejection proof and reconciles its original key', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'EDIT', body: { recordVersion: 0, idempotencyKey: 'unknown-key', remark: 'original' },
attempted: true, rejected: false, result: null }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
expect(recovery.pending.value).toEqual(original)
const rejection = new ApiResponseError('later rejection', 'A0409')
const send = vi.fn().mockRejectedValue(rejection), read = vi.fn()
const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
expect(find).toHaveBeenCalledWith('PC/1', 'unknown-key')
expect(send.mock.calls[0]![0].body).toEqual(original.body)
expect(read).not.toHaveBeenCalled()
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
})
it('only rereads a restored proven result and keeps it when the detail read still fails', async () => {
const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
const original = { dispatchNo: 'PC/1', action: 'LOADING_COMPLETE', body: { recordVersion: 0, idempotencyKey: 'key' },
attempted: true, rejected: false, result: command('LOADING_COMPLETE') }
sessionStorage.setItem(key, JSON.stringify(original))
const recovery = useDispatchCommandRecovery(() => owner)
const send = vi.fn(), find = vi.fn(), failure = new Error('detail unavailable')
const read = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 })
await expect(recovery.execute(send, read, find)).rejects.toBe(failure)
expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
await recovery.execute(send, read, find)
expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
})
it('accepts a loading confirmation that already departed and recovers it without resubmitting', async () => {
const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'DELIVERING', recordVersion: 3 })
const read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 3 })
await expect(recovery.execute(send, read, vi.fn())).rejects.toThrow('read failed')
expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
await recovery.execute(send, read, vi.fn())
expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
it('rejects an account change before sending and releases state',async()=>{
let owner=crypto.randomUUID();const state=useDispatchCommandRecovery(()=>owner)
state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'});owner=crypto.randomUUID()
const send=vi.fn()
await expect(state.execute(send,vi.fn())).rejects.toThrow('账号已变化')
expect(send).not.toHaveBeenCalled();expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})