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
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();
|
|
});
|
|
});
|
|
|