Files
X-Financial/web/tests/expense-case-timeline.test.mjs

258 lines
8.1 KiB
JavaScript
Raw Normal View History

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { effectScope, nextTick, ref } from 'vue'
import {
buildExpenseCaseTimelineViewModel,
isExpenseCaseNotAvailableError
} from '../src/utils/expenseCaseTimeline.js'
import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js'
import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.js'
function createDeferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
async function flushTimelineUpdates() {
await Promise.resolve()
await nextTick()
await Promise.resolve()
}
test('expense case timeline maps and sorts the real business event chain', () => {
const timeline = buildExpenseCaseTimelineViewModel({
id: 'case-1',
case_no: 'CASE-20260713-001',
current_stage: 'reviewing',
status: 'active',
links: [
{ id: 'link-1', relation_type: 'application', resource_id: 'application-1' },
{ id: 'link-2', relation_type: 'generated_reimbursement', resource_id: 'claim-1' }
],
events: [
{
id: 'event-2',
event_type: 'reimbursement_draft_generated',
actor_id: 'system',
occurred_at: '2026-07-13T02:20:00Z',
payload_json: {
application_claim_no: 'AP-20260713-001',
next_status: 'draft',
next_approval_stage: '待提交'
}
},
{
id: 'event-1',
event_type: 'application_submitted',
actor_id: 'employee@example.com',
occurred_at: '2026-07-13T01:10:00Z',
payload_json: {
claim_no: 'AP-20260713-001',
previous_status: 'draft',
next_status: 'submitted',
next_approval_stage: '直属领导审批'
}
}
]
})
assert.equal(timeline.caseNo, 'CASE-20260713-001')
assert.equal(timeline.stageLabel, '审批中')
assert.equal(timeline.linkCount, 2)
assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2'])
assert.equal(timeline.events[0].label, '费用申请已提交')
assert.match(timeline.events[0].summary, /草稿 → 已提交/)
assert.match(timeline.events[0].summary, /直属领导审批/)
assert.equal(timeline.events[0].actorLabel, 'employee@example.com')
assert.equal(timeline.events[1].label, '报销草稿已自动生成')
assert.match(timeline.events[1].summary, /AP-20260713-001/)
assert.equal(timeline.events[1].actorLabel, '系统')
})
test('expense case timeline gives returned events an actionable reason', () => {
const timeline = buildExpenseCaseTimelineViewModel({
current_stage: 'claiming',
events: [
{
id: 'returned-1',
event_type: 'claim_returned',
actor_id: 'finance@example.com',
occurred_at: '2026-07-13T03:00:00Z',
payload_json: {
reason: '住宿票据缺少入住人信息',
previous_status: 'submitted',
next_status: 'returned'
}
}
]
})
assert.equal(timeline.events[0].label, '报销单被退回')
assert.equal(timeline.events[0].tone, 'warning')
assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
})
test('expense case timeline describes application archive against the paid reimbursement', () => {
const timeline = buildExpenseCaseTimelineViewModel({
events: [
{
id: 'archived-1',
event_type: 'application_archived',
occurred_at: '2026-07-13T03:00:00Z',
payload_json: { reimbursement_claim_no: 'BX-20260713-001' }
}
]
})
assert.equal(
timeline.events[0].summary,
'关联报销单 BX-20260713-001 已付款,本费用申请已归档。'
)
})
test('expense case timeline treats 404 as a non-blocking not-yet-covered state', () => {
assert.equal(isExpenseCaseNotAvailableError({ status: 404 }), true)
assert.equal(isExpenseCaseNotAvailableError({ status: 500 }), false)
})
test('expense case timeline keeps equal-time server order and hides unknown event internals', () => {
const timeline = buildExpenseCaseTimelineViewModel({
events: [
{
id: 'unknown-1',
event_type: 'connector_internal_delivery_changed',
occurred_at: '2026-07-13T03:00:00Z',
correlation_id: 'private-correlation',
delivery_status: 'pending'
},
{
id: 'known-2',
event_type: 'payment_completed',
occurred_at: '2026-07-13T03:00:00Z'
}
]
})
assert.deepEqual(timeline.events.map((event) => event.id), ['unknown-1', 'known-2'])
assert.equal(timeline.events[0].label, '流程状态已更新')
assert.doesNotMatch(JSON.stringify(timeline.events[0]), /private-correlation|pending/)
})
test('expense case service safely encodes claim identifiers', async () => {
const originalFetch = globalThis.fetch
let requestedUrl = ''
globalThis.fetch = async (url) => {
requestedUrl = String(url)
return {
ok: true,
status: 200,
json: async () => ({ events: [] })
}
}
try {
await fetchExpenseCaseTimeline(' claim/with space ')
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requestedUrl, '/api/v1/expense-cases/by-claim/claim%2Fwith%20space')
})
test('expense case composable ignores a stale response after switching claims', async () => {
const claimId = ref('claim-a')
const refreshKey = ref('claim-a|submitted')
const requests = new Map([
['claim-a', createDeferred()],
['claim-b', createDeferred()]
])
const scope = effectScope()
let state
scope.run(() => {
state = useExpenseCaseTimeline({
claimId,
refreshKey,
fetchTimeline: (nextClaimId) => requests.get(nextClaimId).promise
})
})
claimId.value = 'claim-b'
refreshKey.value = 'claim-b|approved'
await nextTick()
requests.get('claim-b').resolve({ case_no: 'CASE-B', events: [] })
await flushTimelineUpdates()
assert.equal(state.timeline.value.caseNo, 'CASE-B')
requests.get('claim-a').resolve({ case_no: 'CASE-A', events: [] })
await flushTimelineUpdates()
assert.equal(state.timeline.value.caseNo, 'CASE-B')
scope.stop()
})
test('expense case composable separates 404 compatibility state from retryable errors', async () => {
const claimId = ref('claim-a')
const refreshKey = ref('first')
const scope = effectScope()
let attempt = 0
let state
scope.run(() => {
state = useExpenseCaseTimeline({
claimId,
refreshKey,
fetchTimeline: async () => {
attempt += 1
const error = new Error('request failed')
error.status = attempt === 1 ? 404 : 500
throw error
}
})
})
await flushTimelineUpdates()
assert.equal(state.notAvailable.value, true)
assert.equal(state.errorMessage.value, '')
refreshKey.value = 'retry'
await flushTimelineUpdates()
assert.equal(state.notAvailable.value, false)
assert.match(state.errorMessage.value, /不受影响/)
scope.stop()
})
test('travel request detail consumes the real expense case endpoint', () => {
const detailView = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
'utf8'
)
const progressCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
'utf8'
)
const timelineComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestExpenseCaseTimeline.vue', import.meta.url)),
'utf8'
)
const expenseCaseService = readFileSync(
fileURLToPath(new URL('../src/services/expenseCases.js', import.meta.url)),
'utf8'
)
assert.match(detailView, /:claim-id="request\.claimId"/)
assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/)
assert.match(progressCard, /<TravelRequestExpenseCaseTimeline/)
assert.match(progressCard, /TravelRequestExpenseCaseTimeline/)
assert.match(timelineComponent, /费用事件时间线/)
assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/)
assert.match(expenseCaseService, /\/expense-cases\/by-claim\//)
})