fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
@@ -18,7 +18,7 @@ async function mount(readonly = false, realControls = false) {
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, ApiErrorMeta: true, DataTable: true,
} } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
it.each([[null, 0], [0, null]])('delayed records keep distance %s and fuel %s editable in real number controls', async (distance, fuel) => {
let resolve!: (value: api.TripOperationsRecord) => void
@@ -71,7 +71,7 @@ it('loads current record on open, history only when selected, and hides writes f
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20)
})
it('unknown write retry preserves original key, version and measured values', async () => {
it('unknown write releases fields and the next save uses changed measurements', async () => {
const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network'))
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
@@ -79,8 +79,8 @@ it('unknown write retry preserves original key, version and measured values', as
expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } })
wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99)
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey); expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('a failed initial read cannot save a guessed record version', async () => {
vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount()
@@ -100,29 +100,17 @@ it('uploads optionally without creating records; saving binds only the exact IDs
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].details).toEqual(request.details); expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(request.idempotencyKey)
})
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
it('upload failure keeps bound evidence and allows another file selection', async () => {
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
let storedFiles = 0
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new Error('response lost')).mockResolvedValueOnce(evidence)
const wrapper = await mount(); await choose(wrapper)
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
await choose(wrapper)
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
expect(storedFiles).toBe(1)
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
expect(wrapper.text()).toContain('bound.pdf'); expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(2); expect(wrapper.text()).toContain('meter.pdf')
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
})
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
@@ -179,12 +167,12 @@ it('closing and reopening the same trip cannot start a concurrent save retry', a
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
const retry = wrapper.findAll('button').find(b => b.text() === '保存记录')!
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
reject(new Error('unknown')); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
let done!: (value: api.TripOperationsEvidence) => void