import { beforeEach, expect, it, vi } from 'vitest' 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('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('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('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('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('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('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() })