Files
admin-web/src/modules/inbounds/fee/api.test.ts
T

121 lines
5.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
adjustPublishedInboundFee,
createManualInboundQuote,
confirmInboundFee,
createFeeActionIdempotencyKey,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
previewInboundFee,
sendInboundFee,
} from './api'
vi.mock('@/shared/api/http', async () => {
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }
})
const snapshot = {
feeId: '9007199254740993',
feeNo: 'FBAF-88-2',
feeVersion: 2,
feeStatus: 'SENT',
inboundId: '88',
totalAmount: 1280,
}
describe('inbound fee API', () => {
beforeEach(() => vi.clearAllMocks())
it.each(['', ' ', 'null', 'undefined'])('rejects invalid inboundId %j without issuing HTTP', async (inboundId) => {
await expect(getLatestInboundFee('10001', inboundId)).rejects.toThrow('inboundId is required')
expect(http.get).not.toHaveBeenCalled()
})
it('previews selected outer billing values without a write idempotency header', async () => {
const preview = { actualWeightKg: 570.5, actualVolumeCbm: 3.894, chargeableQuantity: 650.298, priceMatched: true }
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: preview })
const payload = { selectedWeightKg: 570.5, selectedVolumeCbm: 3.894, selectedUnitPrice: 13.6, weightSource: 'ACTUAL', volumeSource: 'OUTER' }
await expect(previewInboundFee('10001', '88', payload)).resolves.toEqual(preview)
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/preview', payload, { params: { tenantId: '10001' } })
})
it('loads the latest immutable fee snapshot with string IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot)
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', {
params: { tenantId: '10001' },
})
})
it('adjusts a published snapshot with the required idempotency key', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费', toleranceConfirmed: true as const }
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
.resolves.toMatchObject({ feeVersion: 3 })
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/inbounds/88/fees/published',
payload,
{ params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } },
)
})
it('confirms and sends a calculated fee with independent idempotency keys', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ code: '200', message: 'success', data: { ...snapshot, feeStatus: 'CONFIRMED' } })
.mockResolvedValueOnce({ code: '200', message: 'success', data: snapshot })
await expect(confirmInboundFee('10001', '88', 'fee-confirm-1')).resolves.toMatchObject({ feeStatus: 'CONFIRMED' })
await expect(sendInboundFee('10001', '88', 'fee-send-1')).resolves.toMatchObject({ feeStatus: 'SENT' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/88/fees/confirm', undefined, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-confirm-1' },
})
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/88/fees/send', undefined, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-send-1' },
})
})
it('creates a manual FBA quote through the dedicated fallback endpoint', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, rateCode: 'MANUAL_QUOTE' } })
const payload = { selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 12.2, reason: 'FBA价格未维护' }
await expect(createManualInboundQuote('10001', '88', payload, 'fee-manual-1'))
.resolves.toMatchObject({ rateCode: 'MANUAL_QUOTE' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/manual-quote', payload, {
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-manual-1' },
})
})
it('preserves backend conflict metadata for stale-version recovery', async () => {
vi.mocked(http.put).mockResolvedValue({
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
requestId: 'request-fee-409', traceId: 'trace-fee-409',
})
const error = await adjustPublishedInboundFee(
'10001',
'88',
{ expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正', toleranceConfirmed: true },
'fee-adjust-2',
).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' })
})
it('creates bounded unique keys for adjustment retries', () => {
const first = createFeeAdjustmentIdempotencyKey()
const second = createFeeAdjustmentIdempotencyKey()
expect(first).toMatch(/^fee-adjust-/)
expect(first.length).toBeLessThanOrEqual(128)
expect(second).not.toBe(first)
expect(createFeeActionIdempotencyKey('confirm')).toMatch(/^fee-confirm-/)
expect(createFeeActionIdempotencyKey('send')).toMatch(/^fee-send-/)
})
})