Browse Source

refactor(api): 按 OpenAPI 校正 89 个接口路径,69 幽灵域退回 mock-only

- 以 job.youthome.com.cn OpenAPI 为唯一准绳,实机验证 89 个 request 函数
- ALIGNED 6 / WRONG→REAL 14(/messages/conversations→/conversations、/resume→/profile/me/resume、/jobs/rank→/jobs/{id}/competition-ranking、/enterprise/candidates→/company/candidates 等)
- PHANTOM 69:所属域后端无契约,真实态不再调用 request,翻开关也不触网
- 清除全部【待确认】标记(含【待确认:…】形式)
- 修复 fetchResume 双前缀、fetchRankData ${jobId} 未插值两处转换 bug
- 4 个桥接测试 + job-api/password 测试改为断言真实路径
master
doublex001 4 weeks ago
parent
commit
55a9222e77
  1. 249
      src/__tests__/backend-bridge-remaining.test.ts
  2. 141
      src/__tests__/backend-bridge-write.test.ts
  3. 118
      src/__tests__/job-api.test.ts
  4. 146
      src/__tests__/message-domain.test.ts
  5. 15
      src/__tests__/password.test.ts
  6. 130
      src/__tests__/profile-resume.test.ts
  7. 906
      src/api/api.ts

249
src/__tests__/backend-bridge-remaining.test.ts

@ -0,0 +1,249 @@
/**
* 后端桥接(剩余域)consolidated 测试
* 覆盖第三轮之后批量转换的 62 个 fetch 函数:
* - 真实态(setMockMode(false)):仅对已与后端 OpenAPI 对齐的函数断言 request 命中真实路径
* (见 REAL_FUNCS;其余为幽灵域,已退回 mock-only,翻真实开关也不触网,不在此断言)
* - mock 态(默认):断言返回非 undefined(防止 mock 分支被意外破坏)
* 路径依据 scripts/openapi.json(job.youthome.com.cn)实测校正,不再标【待确认】。
*/
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
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),
};
});
// storage:内存实现,避免依赖 AsyncStorage
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
return {
storageGet: jest.fn(async (k: string) => m.get(k) ?? null),
storageSet: jest.fn(async (k: string, v: string) => {
m.set(k, v);
}),
storageRemove: jest.fn(async (k: string) => {
m.delete(k);
}),
};
});
import {
fetchArticles,
fetchMenuItems,
fetchEnterpriseEntry,
fetchRankData,
fetchSkillCloud,
fetchSkillLearningPath,
fetchSalaryToolLinks,
fetchSalaryBrackets,
fetchOperationRules,
fetchHollandIntro,
fetchIdentityOptions,
fetchGameLevels,
fetchLegalBrand,
fetchLegalServices,
fetchLegalFAQs,
fetchLegalTemplates,
fetchLegalPlans,
fetchAboutInfo,
fetchQualifications,
fetchAITools,
fetchHistory,
fetchHollandAssessment,
fetchHollandResult,
fetchAbilityAssessment,
fetchUserStats,
fetchOverallScore,
fetchDimensionScores,
fetchBadges,
fetchOfferCompareData,
fetchPendingOfferCount,
fetchWeightSettings,
fetchSalaryRecords,
fetchAIInterviewData,
fetchHREvaluation,
fetchAppealForm,
fetchEnterpriseOverview,
fetchJobPublishForm,
fetchJobEvaluationManagement,
fetchCandidateDetail,
fetchInterviewScore,
fetchInterviewScoreDraft,
fetchAuditReport,
fetchEnterpriseCertification,
fetchJobManagement,
fetchTalentRecommendation,
fetchEnterpriseCommunication,
fetchJobEvaluationEmptyState,
fetchGameProgress,
fetchLegalMembership,
fetchLegalOrders,
fetchLegalServiceRequests,
fetchLegalConversation,
fetchPrivacySettings,
fetchPrivacyCenter,
fetchPrivacyDetailSetting,
fetchDndSetting,
fetchKeywordBlock,
fetchAccountCancel,
fetchLegalDocument,
fetchPersonalDataExport,
fetchNotificationSettings,
fetchAccountSecurity,
} from '../api/api';
import { setMockMode } from '../api/http';
// [fn, args, expectedPath, mockSmoke]
type Entry = [
(...a: any[]) => Promise<any>,
any[],
string,
boolean,
];
const FUNCS: Entry[] = [
// 公开读取(auth:false)
[fetchArticles, [], '/growth/articles', true],
[fetchMenuItems, ['candidate'], '/user/menus', true],
[fetchEnterpriseEntry, [], '/user/enterprise-entry', true],
[fetchRankData, ['1'], '/jobs/rank', false],
[fetchSkillCloud, ['it'], '/tools/skill-cloud', false],
[fetchSkillLearningPath, ['it'], '/tools/skill-learning-path', false],
[fetchSalaryToolLinks, [], '/tools/salary-related', true],
[fetchSalaryBrackets, [], '/tools/salary-brackets', true],
[fetchOperationRules, [], '/operation/rules', true],
[fetchHollandIntro, [], '/growth/holland-intro', true],
[fetchIdentityOptions, [], '/tools/identity-options', true],
[fetchGameLevels, [], '/game/levels', true],
[fetchLegalBrand, [], '/legal/brand', true],
[fetchLegalServices, [], '/legal/services', true],
[fetchLegalFAQs, [], '/legal/faqs', true],
[fetchLegalTemplates, [], '/legal/templates', true],
[fetchLegalPlans, [], '/legal/plans', true],
[fetchAboutInfo, [], '/settings/about', true],
[fetchQualifications, [], '/settings/qualifications', true],
[fetchAITools, [], '/growth/ai-tools', true],
// 用户/企业私有(auth:true)
[fetchHistory, [], '/user/history', true],
[fetchHollandAssessment, [], '/growth/holland', true],
[fetchHollandResult, [], '/growth/holland/result', true],
[fetchAbilityAssessment, [], '/growth/ability', true],
[fetchUserStats, [], '/user/stats', true],
[fetchOverallScore, [], '/user/overall-score', true],
[fetchDimensionScores, [], '/user/dimension-scores', true],
[fetchBadges, [], '/user/badges', true],
[fetchOfferCompareData, [undefined], '/offers/compare', true],
[fetchPendingOfferCount, [], '/offers/pending/count', true],
[fetchWeightSettings, [], '/jobs/weight-settings', true],
[fetchSalaryRecords, [], '/salary/records', true],
[fetchAIInterviewData, [], '/interview/config', true],
[fetchHREvaluation, [], '/evaluation/hr', true],
[fetchAppealForm, [], '/evaluation/appeal', true],
[fetchEnterpriseOverview, [], '/enterprise/overview', true],
[fetchJobPublishForm, [], '/enterprise/jobs/publish-form', true],
[fetchJobEvaluationManagement, [], '/enterprise/job-evaluations', true],
[fetchCandidateDetail, ['1'], '/enterprise/candidates/1', false],
[fetchInterviewScore, ['1'], '/enterprise/interviews/1/score', false],
[fetchInterviewScoreDraft, ['1'], '/enterprise/interviews/1/score/draft', false],
[fetchAuditReport, [], '/operation/audit-report', true],
[fetchEnterpriseCertification, [], '/enterprise/certification', true],
[fetchJobManagement, [], '/enterprise/job-management', true],
[fetchTalentRecommendation, [], '/enterprise/talent-recommendation', true],
[fetchEnterpriseCommunication, [], '/enterprise/communication', true],
[fetchJobEvaluationEmptyState, [], '/enterprise/job-evaluation-empty', true],
[fetchGameProgress, [], '/game/progress', true],
[fetchLegalMembership, [], '/legal/membership', true],
[fetchLegalOrders, [], '/legal/orders', true],
[fetchLegalServiceRequests, [], '/legal/service-requests', true],
[fetchLegalConversation, [], '/legal/conversations/default', true],
[fetchPrivacySettings, [], '/settings/privacy', true],
[fetchPrivacyCenter, [], '/settings/privacy-center', true],
[fetchPrivacyDetailSetting, ['1'], '/settings/privacy/1', false],
[fetchDndSetting, [], '/settings/privacy/dnd', true],
[fetchKeywordBlock, [], '/settings/privacy/keywords', true],
[fetchAccountCancel, [], '/settings/account-cancel', true],
[fetchLegalDocument, ['1'], '/settings/legal-document/1', false],
[fetchPersonalDataExport, [], '/settings/personal-data-export', true],
[fetchNotificationSettings, [], '/settings/notification', true],
[fetchAccountSecurity, [], '/settings/account-security', true],
];
const ENVELOPE = {
code: 0,
message: '',
data: null,
requestId: 'r',
timestamp: '',
};
// 已与后端 OpenAPI 对齐(ALIGNED + WRONG→REAL)的函数:真实态打真实路径
// 幽灵域(PHANTOM,后端无契约)已退回 mock-only,翻真实开关也不触网,不在此断言路径
const REAL_FUNCS: Array<[(...a: any[]) => Promise<any>, any[], string]> = [
[fetchRankData, ['1'], '/jobs/1/competition-ranking'],
[fetchCandidateDetail, ['1'], '/company/candidates/1'],
[fetchInterviewScore, ['1'], '/interviews/1/score'],
[fetchInterviewScoreDraft, ['1'], '/interviews/1/score'],
[fetchOfferCompareData, [undefined], '/offers/compare'],
];
describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
const originalFetch = (global as any).fetch;
beforeEach(() => {
(global as any).fetch = jest.fn(async () => ({
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ENVELOPE,
text: async () => '',
}));
setMockMode(false);
});
afterEach(() => {
setMockMode(true);
});
afterAll(() => {
(global as any).fetch = originalFetch;
});
it('已对齐函数数量正确(5 个)', () => {
expect(REAL_FUNCS.length).toBe(5);
});
REAL_FUNCS.forEach(([fn, args, path], i) => {
it(`#${i} 命中真实路径 ${path}`, async () => {
await fn(...args);
const calls = ((global as any).fetch as jest.Mock).mock.calls;
expect(calls.length).toBeGreaterThan(0);
const calledUrl: string = calls[calls.length - 1][0];
expect(calledUrl).toContain(path);
});
});
});
describe('后端桥接 - mock 态冒烟(无回归)', () => {
beforeEach(() => {
setMockMode(true);
});
FUNCS.filter((e) => e[3]).forEach(([fn, args, path], idx) => {
it(`mock 态 ${path} 返回非 undefined`, async () => {
const result = await fn(...args);
expect(result).not.toBeUndefined();
});
});
});

141
src/__tests__/backend-bridge-write.test.ts

@ -0,0 +1,141 @@
/**
* 后端桥接(写入类)测试
* 覆盖本轮转换的 12 个写函数(简历 8 + 求职偏好 + 权重 + 薪资 2):
* - 真实态(setMockMode(false)):断言 request 命中预期方法 + 相对路径 + body
* - mock 态(默认):断言返回本地派生/生成值(证明 mock 分支未被破坏,本地 storage 真写入仍可用)
* 路径依据 scripts/openapi.json(job.youthome.com.cn)实测校正,不再标【待确认】。
* 幽灵域写函数已退回 mock-only,真实态不在此断言网络路径。
*/
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
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),
};
});
// storage:内存实现,避免依赖 AsyncStorage
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
return {
storageGet: jest.fn(async (k: string) => m.get(k) ?? null),
storageSet: jest.fn(async (k: string, v: string) => {
m.set(k, v);
}),
storageRemove: jest.fn(async (k: string) => {
m.delete(k);
}),
};
});
import { setMockMode } from '../api/http';
import {
saveResumeBasicInfo,
saveResumeSelfEvaluation,
saveResumeSkills,
saveResumeEducation,
saveResumeExperience,
saveResumeProjects,
saveResumeSnapshot,
saveResumeExportSettings,
saveJobPreference,
saveWeightSettings,
saveSalaryRecord,
deleteSalaryRecord,
} from '../api/api';
const ENVELOPE = { code: 0, message: 'ok', data: null, requestId: 'r', timestamp: 0 };
let fetchMock: jest.Mock;
beforeEach(() => {
fetchMock = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ENVELOPE,
text: async () => '',
headers: { get: () => 'x' },
}));
(global as any).fetch = fetchMock;
setMockMode(false); // 默认跑真实态
});
/** 触发一次真实请求,返回最后一个 fetch 调用的 options */
async function expectRequest(
call: () => Promise<unknown>,
path: string,
method: string,
): Promise<any> {
await call();
const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
expect(last[0]).toContain(path);
expect((last[1] as any).method).toBe(method);
return last[1] as any;
}
describe('写入函数真实态:方法 + 相对路径 + body(仅已对齐函数)', () => {
beforeEach(() => setMockMode(false));
test('saveJobPreference → PUT /profile/me/preferences', async () => {
const preference = { city: '北京', salary: '15k' } as any;
const opts = await expectRequest(
() => saveJobPreference(preference),
'/profile/me/preferences',
'PUT',
);
expect(JSON.parse(opts.body)).toEqual(preference);
});
});
/**
* 以下写函数属幽灵域(后端无 OpenAPI 契约),已退回 mock-only,翻真实开关也不触网,
* 故真实态不再断言网络路径;其 mock 态本地派生/生成行为见下方 describe。
*/
describe('写入函数 mock 态:本地派生/生成行为不变(开发期真写入仍可用)', () => {
beforeEach(() => setMockMode(true));
test('saveSalaryRecord 本地生成 id 与时间戳', async () => {
const rec = await saveSalaryRecord({} as any, {} as any, 'note');
expect(rec.id.startsWith('salary-')).toBe(true);
expect(typeof rec.createdAt).toBe('number');
expect(rec.note).toBe('note');
});
test('saveResumeBasicInfo 本地派生 avatar', async () => {
const info = await saveResumeBasicInfo({ name: '张三' } as any);
expect(info.avatar).toBeTruthy();
});
test('saveJobPreference 原样返回入参', async () => {
const p = { city: '北京' } as any;
expect(await saveJobPreference(p)).toBe(p);
});
test('saveResumeSkills 本地去空去重', async () => {
const out = await saveResumeSkills([
{ name: 'a' },
{ name: 'a' },
{ name: ' ' },
] as any);
expect(out).toEqual([{ name: 'a' }]);
});
test('saveWeightSettings 返回副本(不与入参同引用)', async () => {
const dims = [{ id: 'd1' }] as any;
const out = await saveWeightSettings(dims);
expect(out).not.toBe(dims);
expect(out).toEqual(dims);
});
});

118
src/__tests__/job-api.test.ts

@ -5,6 +5,10 @@ import {
fetchJobPreference,
fetchFavorites,
fetchJobProgress,
fetchSalaryData,
fetchEmployeeReviews,
createJobReview,
fetchCompareData,
} from '../api/api';
import { setMockMode, API_BASE_URL } from '../api/http';
@ -102,43 +106,66 @@ describe('岗位域 mock 开关', () => {
expect(score.totalScore).toBe(88);
});
it('真实模式:fetchJobPreference 打 /profile/me/job-preference', async () => {
it('真实模式:fetchJobPreference 打 /profile/me/preferences', async () => {
setMockMode(false);
responder = () => okData({ city: '上海', position: '前端' });
await fetchJobPreference();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/job-preference`);
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/preferences`);
});
it('真实模式:fetchFavorites 打 /profile/me/favorites', async () => {
it('真实模式:fetchFavorites 打 /jobs/favorites', async () => {
setMockMode(false);
responder = () => okData([{ id: '1' }]);
const favs = await fetchFavorites();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/favorites`);
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/favorites`);
expect(favs[0].id).toBe('1');
});
it('真实模式:fetchJobProgress 打 /tools/job-progress', async () => {
it('真实模式:fetchJobProgress 打 /profile/me/career-progress', async () => {
setMockMode(false);
responder = () => okData({ steps: [] });
await fetchJobProgress();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/tools/job-progress`);
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/career-progress`);
});
it('真实模式:fetchJobList 拼装 query(数组逗号、undefined 跳过、分页键 size)', async () => {
it('真实模式:fetchJobList 走 /jobs/search 且 sort 映射 relevance(数组逗号、undefined 跳过、分页键 size)', async () => {
setMockMode(false);
responder = () => okData({ jobs: [], total: 0 });
// 后端返回空 items 时回退 mock,这里给非空 items 让真实分支走完
responder = () =>
okData({
total: 1,
size: 10,
totalPages: 1,
page: 1,
items: [
{
id: '1',
companyName: '测试公司',
companyLocation: '北京',
positionName: '前端工程师',
salaryMin: 20,
salaryMax: 30,
score: 85,
experience: '3-5年',
education: '本科',
industry: '互联网',
city: '北京',
benefits: ['五险一金'],
},
],
});
await fetchJobList({
sortBy: 'comprehensive',
salary: ['10-15K'],
experience: ['3-5年'],
});
const url = fetchCalls[0].url;
expect(url.startsWith(`${API_BASE_URL}/jobs?`)).toBe(true);
expect(url).toContain('sortBy=comprehensive');
expect(url.startsWith(`${API_BASE_URL}/jobs/search?`)).toBe(true);
// sortBy: 'comprehensive' → 后端 sort: 'relevance'
expect(url).toContain('sort=relevance');
expect(url).toContain('page=1');
expect(url).toContain('size=10');
expect(url).toContain('salary=10-15K');
// 中文被 URLSearchParams 编码,解码后断言
expect(decodeURIComponent(url)).toContain('experience=3-5年');
// keyword / industry / city / education 未传 → 不出现在 query 中
expect(url).not.toContain('keyword=');
@ -153,3 +180,72 @@ describe('岗位域 mock 开关', () => {
expect(typeof res.total).toBe('number');
});
});
describe('岗位详情子资源 mock 开关', () => {
it('mock 模式:fetchSalaryData 返回本地薪资且不发请求', async () => {
setMockMode(true);
const data = await fetchSalaryData('1');
expect(fetchCalls.length).toBe(0);
expect(data).toBeDefined();
});
it('真实模式:fetchSalaryData 打 /jobs/{id}/salary', async () => {
setMockMode(false);
responder = () => okData({ min: 10, max: 15 });
const data = await fetchSalaryData('7');
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/7/salary`);
expect(data.min).toBe(10);
});
it('mock 模式:fetchEmployeeReviews 返回本地评价', async () => {
setMockMode(true);
const reviews = await fetchEmployeeReviews('1');
expect(fetchCalls.length).toBe(0);
expect(Array.isArray(reviews)).toBe(true);
});
it('真实模式:fetchEmployeeReviews 打 /jobs/{id}/reviews', async () => {
setMockMode(false);
responder = () => okData([{ id: 'r1', content: '不错' }]);
const reviews = await fetchEmployeeReviews('3');
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/3/reviews`);
expect(reviews[0].id).toBe('r1');
});
it('mock 模式:createJobReview 本地持久化且不发请求', async () => {
setMockMode(true);
const review = await createJobReview('1', '环境友好');
expect(fetchCalls.length).toBe(0);
expect(review.content).toBe('环境友好');
expect(review.id).toContain('my-review-');
});
it('真实模式:createJobReview POST /jobs/{id}/reviews 且 body 含 content', async () => {
setMockMode(false);
responder = (url, init) => {
expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews`);
expect(init.method).toBe('POST');
const body = JSON.parse(String(init.body)) as { content: string };
expect(body.content).toBe('弹性办公');
return okData({ id: 'srv-1', content: '弹性办公' });
};
const review = await createJobReview('4', '弹性办公');
expect(review.id).toBe('srv-1');
});
it('mock 模式:fetchCompareData 返回本地过滤数据', async () => {
setMockMode(true);
const data = await fetchCompareData(['j1', 'j2']);
expect(fetchCalls.length).toBe(0);
expect(Array.isArray(data.jobs)).toBe(true);
});
it('真实模式:fetchCompareData 打 /jobs/compare 且 jobIds 数组逗号拼接', async () => {
setMockMode(false);
responder = () => okData({ jobs: [], dimensionScores: [], basicInfo: { fields: [], values: [] }, benefits: [] });
await fetchCompareData(['a', 'b', 'c']);
const url = fetchCalls[0].url;
expect(url.startsWith(`${API_BASE_URL}/jobs/compare?`)).toBe(true);
expect(decodeURIComponent(url)).toContain('jobIds=a,b,c');
});
});

146
src/__tests__/message-domain.test.ts

@ -0,0 +1,146 @@
import {
fetchConversations,
fetchNotifications,
fetchInterviews,
fetchMatchResult,
fetchConversation,
} from '../api/api';
import { setMockMode, API_BASE_URL } from '../api/http';
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
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),
};
});
// storage:内存实现
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
return {
storageGet: jest.fn(async (k: string) => m.get(k) ?? null),
storageSet: jest.fn(async (k: string, v: string) => {
m.set(k, v);
}),
storageRemove: jest.fn(async (k: string) => {
m.delete(k);
}),
};
});
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: null, requestId: 'r', timestamp: 't' } };
return {
status: spec.status,
ok: spec.status >= 200 && spec.status < 300,
headers: { get: () => 'r' },
text: async () => JSON.stringify(spec.body),
} as unknown as Response;
},
);
}
function okData(data: unknown): MockResponseSpec {
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } };
}
beforeEach(() => {
fetchCalls = [];
responder = null;
installFetch();
});
describe('消息 / 沟通域 mock 开关', () => {
it('mock 模式:fetchConversations 返回本地会话且不发请求', async () => {
setMockMode(true);
const list = await fetchConversations();
expect(fetchCalls.length).toBe(0);
expect(Array.isArray(list)).toBe(true);
});
it('真实模式:fetchConversations 打 /conversations', async () => {
setMockMode(false);
responder = () => okData([{ id: 'c1' }]);
const list = await fetchConversations();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/conversations`);
expect(list[0].id).toBe('c1');
});
it('mock 模式:fetchNotifications 返回本地通知且不发请求', async () => {
setMockMode(true);
const list = await fetchNotifications();
expect(fetchCalls.length).toBe(0);
expect(Array.isArray(list)).toBe(true);
});
it('真实模式:fetchNotifications 打 /notifications', async () => {
setMockMode(false);
responder = () => okData([{ id: 'n1' }]);
const list = await fetchNotifications();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/notifications`);
expect(list[0].id).toBe('n1');
});
it('mock 模式:fetchInterviews 返回本地日程且不发请求', async () => {
setMockMode(true);
const list = await fetchInterviews();
expect(fetchCalls.length).toBe(0);
expect(Array.isArray(list)).toBe(true);
});
it('真实模式:fetchInterviews 打 /interviews/me', async () => {
setMockMode(false);
responder = () => okData([{ id: 'iv1' }]);
const list = await fetchInterviews();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/interviews/me`);
expect(list[0].id).toBe('iv1');
});
it('mock 模式:fetchMatchResult 返回本地结果且不发请求', async () => {
setMockMode(true);
const result = await fetchMatchResult();
expect(fetchCalls.length).toBe(0);
expect(result).toBeDefined();
});
it('mock 模式:fetchConversation 透传 id 且不发请求', async () => {
setMockMode(true);
const detail = await fetchConversation('conv-9');
expect(fetchCalls.length).toBe(0);
expect(detail.id).toBe('conv-9');
});
it('真实模式:fetchConversation 打 /conversations/{id}', async () => {
setMockMode(false);
responder = () => okData({ id: 'conv-9', messages: [] });
const detail = await fetchConversation('conv-9');
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/conversations/conv-9`);
expect(detail.id).toBe('conv-9');
});
});

15
src/__tests__/password.test.ts

@ -128,7 +128,15 @@ describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () =>
makeResponse(200, {
code: 200,
message: 'ok',
data: { accessToken: 'A', refreshToken: 'R' },
data: {
userId: 123,
phone: '13800138000',
nickname: '测试用户',
role: 'STUDENT',
accessToken: 'A',
refreshToken: 'R',
expiresIn: 86400,
},
requestId: 'r1',
timestamp: 't',
}),
@ -191,8 +199,11 @@ describe('sendSmsCode(发送验证码 · 真实后端)', () => {
(global as unknown as { fetch: jest.Mock }).fetch as jest.Mock
).mock.calls[0];
expect(url).toContain('/auth/sms/send');
// sms/send 后端只接受 Query 参数(body 为空)
expect(url).toContain('phone=13800138000');
expect(url).toContain('purpose=login');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ phone: '13800138000', purpose: 'login' });
expect(init.body).toBeUndefined();
});
});

130
src/__tests__/profile-resume.test.ts

@ -0,0 +1,130 @@
import {
fetchUserProfile,
fetchResume,
fetchResumeSavedAt,
fetchResumeExportSettings,
} from '../api/api';
import { setMockMode, API_BASE_URL } from '../api/http';
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
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),
};
});
// storage:内存实现,供 mock 态读取(引导资料 / 简历覆盖层 / 导出设置)
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
return {
storageGet: jest.fn(async (k: string) => m.get(k) ?? null),
storageSet: jest.fn(async (k: string, v: string) => {
m.set(k, v);
}),
storageRemove: jest.fn(async (k: string) => {
m.delete(k);
}),
};
});
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: null, requestId: 'r', timestamp: 't' } };
return {
status: spec.status,
ok: spec.status >= 200 && spec.status < 300,
headers: { get: () => 'r' },
text: async () => JSON.stringify(spec.body),
} as unknown as Response;
},
);
}
function okData(data: unknown): MockResponseSpec {
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } };
}
beforeEach(() => {
fetchCalls = [];
responder = null;
installFetch();
});
describe('个人资料 / 简历 域 mock 开关', () => {
it('mock 模式:fetchUserProfile 返回本地资料且不发请求', async () => {
setMockMode(true);
const profile = await fetchUserProfile();
expect(fetchCalls.length).toBe(0);
expect(profile).toBeDefined();
expect(typeof profile.name).toBe('string');
});
it('真实模式:fetchUserProfile 先打 /profile/me(后端字段空时兜底 /auth/me)', async () => {
setMockMode(false);
// 后端 /profile/me 返回的字段名(nickname / avatarUrl),不是前端 UserProfile 的 name / avatarKey
responder = () => okData({ nickname: '张三', avatarUrl: 'a1' });
const profile = await fetchUserProfile();
// 第一个请求应该是 /profile/me
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me`);
// profile 非空时不请求 auth/me,直接用后端字段映射
expect(fetchCalls.length).toBe(1);
expect(profile.name).toBe('张三');
expect(profile.avatar).toBe('张');
});
it('mock 模式:fetchResume 返回本地派生简历且不发请求', async () => {
setMockMode(true);
const resume = await fetchResume();
expect(fetchCalls.length).toBe(0);
expect(resume).toBeDefined();
expect(Array.isArray(resume.education)).toBe(true);
});
it('真实模式:fetchResume 打 /profile/me/resume', async () => {
setMockMode(false);
responder = () => okData({ basicInfo: {}, education: [], experience: [] });
const resume = await fetchResume();
expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/resume`);
expect(Array.isArray(resume.education)).toBe(true);
});
it('mock 模式:fetchResumeSavedAt 返回空串(从未保存)且不发请求', async () => {
setMockMode(true);
const savedAt = await fetchResumeSavedAt();
expect(fetchCalls.length).toBe(0);
expect(savedAt).toBe('');
});
it('mock 模式:fetchResumeExportSettings 回落默认设置且不发请求', async () => {
setMockMode(true);
const settings = await fetchResumeExportSettings();
expect(fetchCalls.length).toBe(0);
expect(settings).toBeDefined();
});
});

906
src/api/api.ts

File diff suppressed because it is too large
Loading…
Cancel
Save