轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

272 lines
10 KiB

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<string, string>).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<string, string>)['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();
});
});