import { request, checkHealth, ApiError, API_BASE_URL, setUnauthorizedHandler, } from '../api/http'; import { mapBackendRole } from '../api/auth'; import { setTokens, clearTokens, getAccessToken } from '../lib/token-store'; // expo-constants:extra.apiBaseUrl 为空 → 走默认生产域名 jest.mock('expo-constants', () => ({ expoConfig: { extra: {} }, })); // token-store:用可控内存双 Token,避免依赖 AsyncStorage jest.mock('../lib/token-store', () => { let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; return { getAccessToken: jest.fn(() => mem?.accessToken ?? null), getTokens: jest.fn(() => mem), setTokens: jest.fn((a: string, r: string, e?: number) => { mem = { accessToken: a, refreshToken: r, expiresAt: e }; }), clearTokens: jest.fn(() => { mem = null; }), hydrateTokens: jest.fn(async () => undefined), }; }); interface MockResponseSpec { status: number; body: unknown; } type Responder = (url: string, init: RequestInit) => MockResponseSpec; let responder: Responder | null = null; let fetchCalls: Array<{ url: string; init: RequestInit }> = []; function installFetch(): void { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( async (url: string, init: RequestInit) => { fetchCalls.push({ url, init }); const spec = responder ? responder(url, init) : { status: 200, body: { code: 200, message: 'ok', data: {}, requestId: 'rid', timestamp: 't' } }; const text = typeof spec.body === 'string' ? spec.body : JSON.stringify(spec.body); return { ok: spec.status >= 200 && spec.status < 300, status: spec.status, headers: { get: (k: string) => (k === 'X-Request-Id' ? 'rid' : null) }, text: async () => text, }; }, ); } function ok(data: unknown): MockResponseSpec { return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'rid', timestamp: 't' } }; } beforeEach(() => { fetchCalls = []; responder = null; installFetch(); (clearTokens as jest.Mock)(); setUnauthorizedHandler(() => undefined); }); describe('API_BASE_URL(规范 §1)', () => { it('默认生产 HTTPS 域名,路径不在此重复拼 /api/v1', () => { expect(API_BASE_URL).toBe('https://job.youthome.com.cn/api/v1'); }); }); describe('请求信封与鉴权头(规范 §3)', () => { it('成功时返回 data,并自动带 Bearer(已登录)', async () => { (setTokens as jest.Mock)('access-1', 'refresh-1'); responder = () => ok({ id: 'u1', role: 'STUDENT' }); const data = await request<{ id: string }>('/auth/me', { method: 'GET' }); expect(data.id).toBe('u1'); const last = fetchCalls[fetchCalls.length - 1]; expect(last.url).toContain('/auth/me'); expect(last.init.headers).toMatchObject({ Authorization: 'Bearer access-1' }); }); it('匿名接口(auth:false)不带 Authorization', async () => { responder = () => ok({ accessToken: 'A', refreshToken: 'R' }); await request('/auth/password/login', { method: 'POST', auth: false, body: {} }); const last = fetchCalls[fetchCalls.length - 1]; expect((last.init.headers as Record).Authorization).toBeUndefined(); }); it('每个请求都带 X-Request-Id 请求头', async () => { responder = () => ok({}); await request('/health', { method: 'GET', auth: false }); const last = fetchCalls[fetchCalls.length - 1]; expect((last.init.headers as Record)['X-Request-Id']).toMatch(/^app-/); }); }); describe('信封 code 校验(规范 §3)', () => { it('code !== 200 抛 ApiError 且携带 business code 与 requestId', async () => { responder = () => ({ status: 200, body: { code: 4001, message: '业务失败', data: null, requestId: 'rid', timestamp: 't' } }); try { await request('/x', { method: 'GET', auth: false }); throw new Error('不应成功'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).code).toBe(4001); expect((e as ApiError).httpStatus).toBe(200); expect((e as ApiError).requestId).toBe('rid'); } }); it('HTTP 非 2xx 且信封带 message 时,优先用后端 message(更具体)', async () => { responder = () => ({ status: 404, body: { code: 404, message: '职位不存在', data: null, requestId: 'rid', timestamp: 't' } }); try { await request('/missing', { method: 'GET', auth: false }); throw new Error('不应成功'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).httpStatus).toBe(404); expect((e as ApiError).message).toBe('职位不存在'); } }); it('HTTP 非 2xx 且错误体没有 data 字段时,仍透传后端 message(回归:验证码限流曾显示「请求参数有误」)', async () => { // 真实后端错误体是 {code, message, requestId, timestamp},不含 data 字段; // 旧实现用 isEnvelope(要求 data)判断,恒为 false,把后端 message 覆盖成「请求参数有误」 responder = () => ({ status: 400, body: { code: 3005, message: 'sms rate limited', requestId: 'rid', timestamp: 't' } }); try { await request('/auth/sms/send', { method: 'POST', auth: false, query: { phone: '19900009021', purpose: 'login' } }); throw new Error('不应成功'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).httpStatus).toBe(400); expect((e as ApiError).code).toBe(3005); expect((e as ApiError).message).toBe('sms rate limited'); } }); it('HTTP 非 2xx 且信封无 message 时,回退到状态码映射文案(§8)', async () => { responder = () => ({ status: 404, body: '' }); try { await request('/missing', { method: 'GET', auth: false }); throw new Error('不应成功'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).httpStatus).toBe(404); expect((e as ApiError).message).toBe('请求的资源不存在'); } }); }); describe('401 单飞刷新 + 重放(规范 §4)', () => { it('Access Token 过期:一次刷新成功后重放原请求并返回 data', async () => { (setTokens as jest.Mock)('old-access', 'refresh-1'); // 前 1 次保护接口返回 401,刷新接口返回新 Token,重放返回 200 let calls = 0; responder = (url: string) => { if (url.includes('/auth/refresh')) { return ok({ accessToken: 'new-access', refreshToken: 'refresh-2' }); } calls += 1; if (calls === 1) return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; return ok({ id: 'u1', role: 'STUDENT' }); }; const data = await request<{ id: string }>('/auth/me', { method: 'GET' }); expect(data.id).toBe('u1'); // 只刷新了一次 const refreshCalls = fetchCalls.filter((c) => c.url.includes('/auth/refresh')); expect(refreshCalls).toHaveLength(1); // 刷新后内存 Token 已更新 expect((getAccessToken as jest.Mock)()).toBe('new-access'); }); it('并发 401 只触发一次刷新(多标签并发等价物)', async () => { (setTokens as jest.Mock)('old-access', 'refresh-1'); let protectedCalls = 0; responder = (url: string) => { if (url.includes('/auth/refresh')) { return ok({ accessToken: 'new-access', refreshToken: 'refresh-2' }); } protectedCalls += 1; // 前 3 次(并发)都 401,之后重放成功 if (protectedCalls <= 3) return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; return ok({ ok: true }); }; const results = await Promise.all([ request('/a', { method: 'GET' }), request('/b', { method: 'GET' }), request('/c', { method: 'GET' }), ]); expect(results).toHaveLength(3); const refreshCalls = fetchCalls.filter((c) => c.url.includes('/auth/refresh')); expect(refreshCalls).toHaveLength(1); }); it('刷新也失败:清 Token、调用 unauthorizedHandler、抛 ApiError', async () => { (setTokens as jest.Mock)('old-access', 'refresh-1'); let handlerCalled = false; setUnauthorizedHandler(() => { handlerCalled = true; }); responder = (url: string) => { if (url.includes('/auth/refresh')) { return { status: 401, body: { code: 401, message: 'bad refresh', data: null, requestId: 'rid', timestamp: 't' } }; } return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; }; await expect(request('/a', { method: 'GET' })).rejects.toBeInstanceOf(ApiError); expect(handlerCalled).toBe(true); expect((getAccessToken as jest.Mock)()).toBeNull(); }); }); describe('网络层异常(规范 §8 / §10 降级)', () => { it('fetch 抛错时降级为网络异常,不回吐 Token', async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { throw new Error('network down'); }); try { await request('/a', { method: 'GET', auth: false }); throw new Error('不应成功'); } catch (e) { expect(e).toBeInstanceOf(ApiError); expect((e as ApiError).httpStatus).toBe(0); expect((e as ApiError).message).toContain('网络'); } }); }); describe('checkHealth(规范 §1)', () => { it('UP 返回 true', async () => { responder = () => ({ status: 200, body: { code: 200, message: 'ok', data: { status: 'UP' }, requestId: 'rid', timestamp: 't' } }); expect(await checkHealth()).toBe(true); }); it('非 UP 或异常返回 false', async () => { responder = () => ({ status: 200, body: { code: 200, message: 'ok', data: { status: 'DOWN' }, requestId: 'rid', timestamp: 't' } }); expect(await checkHealth()).toBe(false); }); }); describe('mapBackendRole(规范 §5 角色映射)', () => { it('后端角色映射到应用内部角色', () => { expect(mapBackendRole('STUDENT')).toBe('candidate'); expect(mapBackendRole('COMPANY')).toBe('enterprise'); expect(mapBackendRole('ADMIN')).toBe('admin'); }); it('UNASSIGNED(未选身份)返回 null,不得当成求职者放行', () => { // 新手机号首次验证码登录即为 UNASSIGNED,此时必须进身份选择页 expect(mapBackendRole('UNASSIGNED')).toBeNull(); }); it('认不出来的角色返回 null,不臆造身份', () => { expect(mapBackendRole('UNKNOWN')).toBeNull(); expect(mapBackendRole('')).toBeNull(); }); });