This commit is contained in:
2026-09-29 17:56:00 +08:00
parent 4ead51a5fe
commit 2bf4353b1b
46 changed files with 481 additions and 638 deletions
+2 -4
View File
@@ -1,23 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from './http'
import { overseasHttp } from './overseasHttp'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations'
vi.mock('./http', async (original) => ({ ...(await original<typeof import('./http')>()), http: { get: vi.fn() } }))
vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('inbound-code relationship APIs', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the frozen domestic and overseas read models and encodes the business code', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
await getPlatformInboundCodeAssociation('IN/01')
await getLoadingAssociationSummary('IN/01')
await getOverseasInboundCodeRelationship('IN/01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
})
it('does not accept customer or internal ids as lookup parameters', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import { http, unwrapApiResponse, type ApiEnvelope } from './http'
import { overseasHttp } from './overseasHttp'
export interface PlatformInboundCodeAssociation {
inboundCode: string
@@ -68,7 +67,7 @@ export async function getLoadingAssociationSummaryByInboundId(inboundId: string)
}
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
const response = await http.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
-99
View File
@@ -1,99 +0,0 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { registerAuthRefreshHandler } from './http'
import { clearOverseasAccessToken, overseasHttp, replaceOverseasAccessToken } from './overseasHttp'
const originalOverseasAdapter = overseasHttp.defaults.adapter
function success(config: InternalAxiosRequestConfig, data: unknown) {
return Promise.resolve({ status: 200, statusText: 'OK', headers: {}, config, data })
}
describe('overseas HTTP client', () => {
beforeEach(() => {
localStorage.setItem('cc-admin-token', 'domestic-token')
clearOverseasAccessToken()
registerAuthRefreshHandler(async () => 'domestic-token')
})
afterEach(() => {
overseasHttp.defaults.adapter = originalOverseasAdapter
localStorage.clear()
clearOverseasAccessToken()
})
it('uses the overseas token supplied by the login session for concurrent requests', async () => {
replaceOverseasAccessToken('overseas-token', 900)
const authorizations: unknown[] = []
overseasHttp.defaults.adapter = async (config) => {
authorizations.push(config.headers.Authorization)
return success(config, { code: '200', message: 'success', data: [] })
}
await Promise.all([
overseasHttp.get('/platform/v1/last-leg/orders'),
overseasHttp.get('/platform/v1/warehouse-admin'),
])
expect(authorizations).toEqual(['Bearer overseas-token', 'Bearer overseas-token'])
})
it('refreshes the unified session once and replays once after an overseas 401', async () => {
replaceOverseasAccessToken('overseas-1', 900)
const refresh = vi.fn(async () => {
replaceOverseasAccessToken('overseas-2', 900)
return 'domestic-2'
})
registerAuthRefreshHandler(refresh)
let requests = 0
const authorizations: unknown[] = []
overseasHttp.defaults.adapter = async (config) => {
authorizations.push(config.headers.Authorization)
if (requests++ === 0) {
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
status: 401,
statusText: 'Unauthorized',
headers: {},
config,
data: { code: 'A0401', message: '海外令牌失效' },
})
}
return success(config, { code: '200', message: 'success', data: [] })
}
await overseasHttp.get('/platform/v1/last-leg/orders')
expect(refresh).toHaveBeenCalledTimes(1)
expect(requests).toBe(2)
expect(authorizations).toEqual(['Bearer overseas-1', 'Bearer overseas-2'])
})
it('refreshes the unified session when no usable overseas token is present', async () => {
const refresh = vi.fn(async () => {
replaceOverseasAccessToken('overseas-refreshed', 900)
return 'domestic-refreshed'
})
registerAuthRefreshHandler(refresh)
let authorization: unknown
overseasHttp.defaults.adapter = async (config) => {
authorization = config.headers.Authorization
return success(config, { code: '200', message: 'success', data: [] })
}
await overseasHttp.get('/platform/v1/last-leg/orders')
expect(refresh).toHaveBeenCalledTimes(1)
expect(authorization).toBe('Bearer overseas-refreshed')
})
it('fails before sending a business request when refresh is domestic-only', async () => {
const refresh = vi.fn(async () => 'domestic-only')
registerAuthRefreshHandler(refresh)
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => success(config, { code: '200', message: 'success', data: [] }))
overseasHttp.defaults.adapter = adapter
await expect(overseasHttp.get('/platform/v1/last-leg/orders')).rejects.toThrow('当前环境未签发海外访问令牌')
expect(refresh).toHaveBeenCalledTimes(1)
expect(adapter).not.toHaveBeenCalled()
})
})
-77
View File
@@ -1,77 +0,0 @@
import axios, { type InternalAxiosRequestConfig } from 'axios'
import { parseApiJsonResponse } from '@chongchong/shared'
import { refreshAuthenticatedSession } from './http'
interface CachedOverseasToken {
accessToken: string
refreshAfter: number
}
type RetryableOverseasRequestConfig = InternalAxiosRequestConfig & { _overseasAuthRetry?: boolean }
const TOKEN_REFRESH_SAFETY_WINDOW_MS = 30_000
let cachedToken: CachedOverseasToken | null = null
export const overseasHttp = axios.create({
baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
timeout: 15_000,
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
})
function currentOverseasAccessToken() {
return cachedToken && cachedToken.refreshAfter > Date.now() ? cachedToken.accessToken : ''
}
async function requireOverseasAccessToken() {
const current = currentOverseasAccessToken()
if (current) return current
await refreshAuthenticatedSession()
const refreshed = currentOverseasAccessToken()
if (!refreshed) throw new Error('当前环境未签发海外访问令牌')
return refreshed
}
export function replaceOverseasAccessToken(accessToken: string | null, expiresIn: number) {
if (!accessToken && expiresIn === 0) {
cachedToken = null
return
}
if (!accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
throw new Error('海外访问令牌响应无效')
}
cachedToken = {
accessToken,
refreshAfter: Date.now() + Math.max(0, expiresIn * 1000 - TOKEN_REFRESH_SAFETY_WINDOW_MS),
}
}
export function clearOverseasAccessToken() {
cachedToken = null
}
overseasHttp.interceptors.request.use(async (config) => {
const accessToken = await requireOverseasAccessToken()
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
overseasHttp.interceptors.response.use(
(response) => response.data,
async (error) => {
const config = error.config as RetryableOverseasRequestConfig | undefined
if (error.response?.status !== 401 || !config || config._overseasAuthRetry) {
return Promise.reject(error)
}
config._overseasAuthRetry = true
clearOverseasAccessToken()
try {
await refreshAuthenticatedSession()
const accessToken = await requireOverseasAccessToken()
config.headers.Authorization = `Bearer ${accessToken}`
return overseasHttp.request(config)
} catch {
return Promise.reject(error)
}
},
)
+49
View File
@@ -0,0 +1,49 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, registerAuthRefreshHandler } from './http'
const originalAdapter = http.defaults.adapter
const paths = ['/platform/v1/orders', '/platform/v1/last-leg/orders', '/platform/v1/overseas-warehouse-resources/pallets']
describe('unified business session', () => {
beforeEach(() => localStorage.setItem('cc-admin-token', 'session-1'))
afterEach(() => {
http.defaults.adapter = originalAdapter
localStorage.clear()
registerAuthRefreshHandler(async () => { throw new Error('no active test session') })
})
it('uses one base URL and the same token across order, lastleg and warehouse resources', async () => {
const requests: unknown[] = []
const refresh = vi.fn()
registerAuthRefreshHandler(refresh)
http.defaults.adapter = async (config) => {
requests.push([config.baseURL, config.headers.Authorization])
return { status: 200, statusText: 'OK', headers: {}, config, data: { code: '200', data: [] } }
}
await Promise.all(paths.map(path => http.get(path)))
expect(requests).toEqual(paths.map(() => [http.defaults.baseURL, 'Bearer session-1']))
expect(refresh).not.toHaveBeenCalled()
})
it('shares a single refresh across simultaneous 401s from both business domains', async () => {
const refresh = vi.fn(async () => {
await Promise.resolve()
localStorage.setItem('cc-admin-token', 'session-2')
return 'session-2'
})
registerAuthRefreshHandler(refresh)
const successes: string[] = []
http.defaults.adapter = async (config) => {
if (config.headers.Authorization === 'Bearer session-1') {
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined,
{ status: 401, statusText: 'Unauthorized', headers: {}, config, data: {} })
}
successes.push(String(config.headers.Authorization))
return { status: 200, statusText: 'OK', headers: {}, config, data: { code: '200', data: [] } }
}
await Promise.all(paths.map(path => http.get(path)))
expect(refresh).toHaveBeenCalledTimes(1)
expect(successes).toEqual(paths.map(() => 'Bearer session-2'))
})
})