Browse Source
- 以 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
7 changed files with 1437 additions and 268 deletions
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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'); |
||||
|
}); |
||||
|
}); |
||||
@ -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(); |
||||
|
}); |
||||
|
|
||||
|
}); |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue