import test from 'node:test' import assert from 'node:assert/strict' import { overseasDownload, overseasRequest, overseasUpload, resetOverseasRequestStateForTest } from '../src/api/overseas.js' import { resolvePallet } from '../src/api/pallet.js' import { PDA_AUTHORITY, PDA_ROLE, session, signInWithTokens, signOut } from '../src/stores/session.js' const future = () => Math.floor(Date.now() / 1000) + 3600 const jwt = (claims) => [ Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature' ].join('.') function tokens(version = 'old') { const base = { user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM', overseas_warehouse_id: 900, exp: future(), version } return { accessToken: jwt({ ...base, username: 'picker-42' }), refreshToken: `refresh-${version}`, roles: [PDA_ROLE], authorities: [PDA_AUTHORITY] } } function prepareSession() { globalThis.uni = { setStorageSync() {}, getStorageSync() {}, removeStorageSync() {}, launches: [], reLaunch(options) { this.launches.push(options.url) } } resetOverseasRequestStateForTest(); signOut() const value = tokens('old') signInWithTokens(value) return value } const ok = (data, headers = {}) => ({ statusCode: 200, data: { code: '200', data }, header: headers }) const denied = (requestId = 'request-401', traceId = 'trace-401') => ({ statusCode: 401, data: { code: 'A0401', message: 'expired', requestId, traceId }, header: {} }) test('H5 proxy and App API roots normalize request upload and download paths to one public api prefix', async () => { prepareSession() const requests = []; const uploads = []; const downloads = [] globalThis.uni.request = (options) => { requests.push(options.url); options.success(ok({ palletCode: 'PLT-1' })) } globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) } globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) } session.apiBase = '/gateway-api' await resolvePallet('PLT-1') await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg') await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') session.apiBase = 'https://overseas.example.com/api/' await resolvePallet('PLT-1') await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg') await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') assert.deepEqual(requests, [ '/gateway-api/pda/v1/last-leg/pallet-operations/pallets/resolve', 'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve' ]) assert.deepEqual(uploads, [ '/gateway-api/pda/v1/last-leg/photos/files', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files' ]) assert.deepEqual(downloads, [ '/gateway-api/pda/v1/last-leg/photos/files/1/download', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download' ]) for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//) }) test('concurrent old-token 401 responses share one refresh and each replay once with the atomically replaced token', async () => { const old = prepareSession(); const next = tokens('new') let refreshCount = 0; let oldCalls = 0; let newCalls = 0 globalThis.uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) { refreshCount += 1 return setTimeout(() => options.success(ok(next)), 10) } if (options.header.Authorization === `Bearer ${old.accessToken}`) { oldCalls += 1 return setTimeout(() => options.success(denied()), oldCalls === 1 ? 0 : 30) } assert.equal(options.header.Authorization, `Bearer ${next.accessToken}`) newCalls += 1 setTimeout(() => options.success(ok({ call: newCalls })), 0) } const results = await Promise.all([ overseasRequest('GET', '/pda/one'), overseasRequest('GET', '/pda/two') ]) assert.equal(results.length, 2) assert.equal(refreshCount, 1) assert.equal(oldCalls, 2) assert.equal(newCalls, 2) assert.equal(session.token, next.accessToken) }) test('request retries only once and preserves the final request and trace identifiers', async () => { prepareSession(); const next = tokens('retry') let businessCalls = 0; let refreshCount = 0 globalThis.uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) { refreshCount += 1 return options.success(ok(next)) } businessCalls += 1 options.success(denied(`request-${businessCalls}`, `trace-${businessCalls}`)) } await assert.rejects(overseasRequest('POST', '/pda/write', { value: 1 }), (error) => { assert.equal(error.code, 'A0401') assert.equal(error.requestId, 'request-2') assert.equal(error.traceId, 'trace-2') return true }) assert.equal(refreshCount, 1) assert.equal(businessCalls, 2) assert.equal(session.token, '') assert.deepEqual(uni.launches, ['/pages/login/login']) }) test('refresh rejection clears every local token and returns the auth trace', async () => { prepareSession() globalThis.uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) { return options.success({ statusCode: 200, data: { code: 'A0401', message: 'refresh expired', requestId: 'auth-request', traceId: 'auth-trace' }, header: {} }) } options.success(denied()) } await assert.rejects(overseasRequest('GET', '/pda/current'), (error) => { assert.equal(error.requestId, 'auth-request') assert.equal(error.traceId, 'auth-trace') return true }) assert.equal(session.currentUser, null) assert.equal(session.token, '') assert.equal(session.refreshToken, '') assert.deepEqual(uni.launches, ['/pages/login/login']) }) test('concurrent refresh rejection clears the session and navigates to login only once', async () => { prepareSession() let refreshCount = 0 uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) { refreshCount += 1 return setTimeout(() => options.success(denied()), 5) } options.success(denied()) } const results = await Promise.allSettled([overseasRequest('GET', '/one'), overseasRequest('GET', '/two')]) assert.ok(results.every(result => result.status === 'rejected')) assert.equal(refreshCount, 1) assert.equal(session.currentUser, null) assert.deepEqual(uni.launches, ['/pages/login/login']) }) for (const refreshSucceeds of [true, false]) { test(`late refresh ${refreshSucceeds ? 'success' : 'failure'} cannot restore logout or clear a new login`, async () => { for (const relogin of [false, true]) { prepareSession() let completeRefresh let started const refreshStarted = new Promise(resolve => { started = resolve }) let businessCalls = 0 uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) { completeRefresh = () => options.success(refreshSucceeds ? ok(tokens('late')) : denied()) started() return } businessCalls += 1 options.success(denied()) } const pending = overseasRequest('POST', '/write') await refreshStarted signOut() const replacement = tokens('new-login') if (relogin) signInWithTokens(replacement) completeRefresh() await assert.rejects(pending) assert.equal(session.token, relogin ? replacement.accessToken : '') assert.equal(businessCalls, 1) assert.deepEqual(uni.launches, []) } }) } test('an old business response cannot replay with a newly logged-in session', async () => { prepareSession() let complete let calls = 0 uni.request = options => { calls += 1; complete = () => options.success(denied()) } const pending = overseasRequest('POST', '/write') signOut() const replacement = tokens('another-login') signInWithTokens(replacement) complete() await assert.rejects(pending, /会话已变更/) assert.equal(calls, 1) assert.equal(session.token, replacement.accessToken) assert.deepEqual(uni.launches, []) }) for (const kind of ['upload', 'download']) { test(`${kind} final unauthorized response navigates once and stops after one replay`, async () => { prepareSession() let refreshCount = 0 let calls = 0 uni.request = options => { refreshCount += 1; options.success(ok(tokens(kind))) } uni.uploadFile = options => { calls += 1; options.success({ ...denied(), data: JSON.stringify(denied().data) }) } uni.downloadFile = options => { calls += 1; options.success(denied()) } await assert.rejects(kind === 'upload' ? overseasUpload('/photo', '/tmp/photo.jpg') : overseasDownload('/photo')) assert.equal(refreshCount, 1) assert.equal(calls, 2) assert.equal(session.currentUser, null) assert.deepEqual(uni.launches, ['/pages/login/login']) }) } test('photo upload refreshes and replays once while retaining the file payload', async () => { const old = prepareSession(); const next = tokens('upload') let refreshCount = 0; const uploads = [] globalThis.uni.request = (options) => { refreshCount += 1 assert.ok(options.url.endsWith('/auth/refresh')) options.success(ok(next)) } globalThis.uni.uploadFile = (options) => { uploads.push({ filePath: options.filePath, name: options.name, authorization: options.header.Authorization }) if (options.header.Authorization === `Bearer ${old.accessToken}`) { return options.success({ statusCode: 401, data: JSON.stringify({ code: 'A0401', requestId: 'upload-old', traceId: 'upload-old-trace' }), header: {} }) } options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '99' } }), header: {} }) } assert.deepEqual(await overseasUpload('/pda/photo', '/tmp/photo.jpg'), { fileId: '99' }) assert.equal(refreshCount, 1) assert.equal(uploads.length, 2) assert.deepEqual(uploads.map(({ filePath, name }) => ({ filePath, name })), [ { filePath: '/tmp/photo.jpg', name: 'file' }, { filePath: '/tmp/photo.jpg', name: 'file' } ]) }) test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile', async () => { const value = prepareSession() const originalWindow = globalThis.window const originalLocation = globalThis.location const originalFetch = globalThis.fetch let uploadFileCalls = 0 let uploaded globalThis.window = {} globalThis.location = { protocol: 'http:' } globalThis.uni.uploadFile = () => { uploadFileCalls += 1 } globalThis.fetch = async (url, options) => { if (String(url).startsWith('blob:')) return new Response(new Blob(['photo'], { type: 'image/png' })) uploaded = { url, options } return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 }) } try { session.apiBase = '/gateway-api' assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' }) assert.equal(uploadFileCalls, 0) assert.equal(uploaded.url, '/gateway-api/pda/v1/last-leg/photos/files') assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`) assert.ok(uploaded.options.body instanceof FormData) } finally { globalThis.fetch = originalFetch if (originalWindow === undefined) delete globalThis.window else globalThis.window = originalWindow if (originalLocation === undefined) delete globalThis.location else globalThis.location = originalLocation } }) test('H5 File photo is appended directly without rereading a temporary blob URL', async () => { const value = prepareSession() const originalWindow = globalThis.window const originalLocation = globalThis.location globalThis.window = {} globalThis.location = { protocol: 'http:' } const selected = new Blob(['photo'], { type: 'image/png' }) let calls = 0 globalThis.uni.uploadFile = (options) => { calls += 1 assert.equal(options.url, '/gateway-api/pda/photo') assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`) assert.equal(options.files[0].file, selected) assert.equal(options.files[0].name, 'file') options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} }) } try { session.apiBase = '/gateway-api' assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' }) assert.equal(calls, 1) } finally { if (originalWindow === undefined) delete globalThis.window else globalThis.window = originalWindow if (originalLocation === undefined) delete globalThis.location else globalThis.location = originalLocation } }) test('native file runtime rejects an H5-relative overseas root before upload with a clear configuration error', async () => { prepareSession() const originalLocation = globalThis.location globalThis.location = { protocol: 'file:' } session.apiBase = '/gateway-api' let uploadCalls = 0 globalThis.uni.uploadFile = () => { uploadCalls += 1 } try { await assert.rejects(overseasUpload('/pda/photo', '_doc/photo.jpg'), (error) => { assert.equal(error.code, 'PDA_API_BASE_REQUIRED') assert.match(error.message, /VITE_API_BASE_URL/) assert.match(error.message, /HTTP\(S\)/) return true }) assert.equal(uploadCalls, 0) } finally { if (originalLocation === undefined) delete globalThis.location else globalThis.location = originalLocation } }) test('native file runtime uploads to an absolute overseas root', async () => { prepareSession() const originalLocation = globalThis.location globalThis.location = { protocol: 'file:' } session.apiBase = 'https://overseas.example.com/api/' let call globalThis.uni.uploadFile = (options) => { call = options options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'native-1' } }), header: {} }) } try { assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', '_doc/photo.jpg'), { fileId: 'native-1' }) assert.equal(call.url, 'https://overseas.example.com/api/pda/v1/last-leg/photos/files') assert.equal(call.filePath, '_doc/photo.jpg') } finally { if (originalLocation === undefined) delete globalThis.location else globalThis.location = originalLocation } }) test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => { const value = prepareSession(); const calls = [] globalThis.uni.downloadFile = (options) => { calls.push(options) options.success({ statusCode: 200, tempFilePath: '/tmp/restored.jpg', header: {} }) } assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg') assert.equal(calls.length, 1) assert.equal(calls[0].url, '/gateway-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download') assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`) }) test('native image download accepts a valid temp file when App-Plus omits response headers', async () => { prepareSession() globalThis.uni.downloadFile = (options) => options.success({ statusCode: 200, tempFilePath: '_doc/uniapp_temp/photo.jpg', header: {} }) assert.equal( await overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }), '_doc/uniapp_temp/photo.jpg' ) }) test('image download rejects a JSON error envelope returned with HTTP 200', async () => { prepareSession() globalThis.uni.downloadFile = (options) => options.success({ statusCode: 200, tempFilePath: '/tmp/error.json', header: { 'Content-Type': 'application/json', 'X-Request-Id': 'photo-request', 'X-Trace-Id': 'photo-trace' } }) await assert.rejects( overseasDownload('/api/pda/v1/last-leg/photo', { expectedContentTypePrefix: 'image/' }), (error) => error.message === '照片服务返回了无效内容,请稍后重试' && error.requestId === 'photo-request' && error.traceId === 'photo-trace' ) }) test('business and network errors retain diagnostics without refreshing or clearing a valid session', async () => { prepareSession(); let refreshCount = 0 globalThis.uni.request = (options) => { if (options.url.endsWith('/auth/refresh')) refreshCount += 1 options.success({ statusCode: 200, data: { code: 'A0409', message: '当前柜已分配至其他海外接柜仓,无法在本仓操作', requestId: 'business-request', traceId: 'business-trace' }, header: {} }) } await assert.rejects(overseasRequest('POST', '/pda/locked'), (error) => { assert.equal(error.code, 'A0409') assert.equal(error.message, '当前柜已分配至其他海外接柜仓,无法在本仓操作') assert.equal(error.requestId, 'business-request') assert.equal(error.traceId, 'business-trace') return true }) assert.equal(refreshCount, 0) assert.ok(session.currentUser) globalThis.uni.request = (options) => options.fail({ errMsg: 'socket disconnected' }) await assert.rejects(overseasRequest('GET', '/pda/network'), (error) => error.network === true && /socket/.test(error.message)) assert.ok(session.currentUser) })