From 5bd44774b9f20897080ad879df28469daa559b96 Mon Sep 17 00:00:00 2001 From: win Date: Sat, 21 Mar 2026 19:03:01 +0800 Subject: [PATCH] test(02-02): add Boss HTTP layer mock tests (QUAL-03) - 22 tests covering SearchRecJobs, GetBrandDetail, SearchBrandJobs, GetJobDetail, BossClient - Uses MagicMock (requests_go not compatible with respx) - Covers success responses, HTTP errors, biz errors, Traceid injection - All 22 tests pass (0.08s) --- tests/boss/__init__.py | 1 + tests/boss/test_boss_client.py | 271 +++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 tests/boss/__init__.py create mode 100644 tests/boss/test_boss_client.py diff --git a/tests/boss/__init__.py b/tests/boss/__init__.py new file mode 100644 index 0000000..0c1bab2 --- /dev/null +++ b/tests/boss/__init__.py @@ -0,0 +1 @@ +# tests/boss/ diff --git a/tests/boss/test_boss_client.py b/tests/boss/test_boss_client.py new file mode 100644 index 0000000..9f76400 --- /dev/null +++ b/tests/boss/test_boss_client.py @@ -0,0 +1,271 @@ +""" +Boss 直聘 HTTP 层 mock 测试(QUAL-03) + +使用 unittest.mock.MagicMock 替代真实 HTTP 客户端, +覆盖正常响应和错误响应场景,无网络依赖。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from spiderJobs.platforms.boss.api import ( + GetBrandDetail, + GetJobDetail, + SearchBrandJobs, + SearchRecJobs, + _parse_boss_response, +) +from spiderJobs.platforms.boss.client import BossClient +from crawler_core.base import Result + + +# ───────────────────────────────────────────────────────── +# 1. _parse_boss_response 纯函数测试 +# ───────────────────────────────────────────────────────── + +class TestParseBossResponse: + + def test_http_error_returns_failure(self): + result = _parse_boss_response(500, {}) + assert result.success is False + assert result.status_code == 500 + + def test_non_dict_raw_returns_failure(self): + result = _parse_boss_response(200, "not a dict") + assert result.success is False + + def test_biz_error_code_35_returns_failure(self): + result = _parse_boss_response(200, {"code": 35, "message": "IP地址存在异常"}) + assert result.success is False + assert result.status_code == 35 + assert "IP" in result.error + + def test_joblist_payload_parsed_correctly(self): + raw = { + "code": 0, + "zpData": { + "jobList": [{"title": "Python工程师"}], + "hasMore": True, + }, + } + result = _parse_boss_response(200, raw) + assert result.success is True + assert len(result.list) == 1 + assert result.list[0]["title"] == "Python工程师" + assert result.is_end_page is False # hasMore=True → is_end_page=False + + def test_joblist_no_more_pages(self): + raw = { + "code": 0, + "zpData": {"jobList": [{"title": "测试"}], "hasMore": False}, + } + result = _parse_boss_response(200, raw) + assert result.is_end_page is True + + def test_detail_payload(self): + raw = {"code": 0, "zpData": {"companyName": "测试公司"}} + result = _parse_boss_response(200, raw) + assert result.success is True + assert result.data == {"companyName": "测试公司"} + + def test_list_field_payload_parsed(self): + raw = { + "code": 0, + "zpData": {"list": [{"jobName": "运营岗"}], "hasMore": True}, + } + result = _parse_boss_response(200, raw) + assert result.success is True + assert len(result.list) == 1 + assert result.is_end_page is False + + +# ───────────────────────────────────────────────────────── +# 2. SearchRecJobs +# ───────────────────────────────────────────────────────── + +class TestSearchRecJobs: + + def _make_mock_client(self, return_value): + mock_client = MagicMock() + mock_client.get.return_value = return_value + return mock_client + + def test_search_success(self): + raw = { + "code": 0, + "zpData": { + "jobList": [{"title": "测试职位1"}, {"title": "测试职位2"}], + "hasMore": False, + }, + } + searcher = SearchRecJobs(city_code="101010100", client=self._make_mock_client((200, raw))) + result = searcher.search(page_index=1) + + assert result.success is True + assert len(result.list) == 2 + assert result.is_end_page is True + + def test_search_http_error(self): + searcher = SearchRecJobs(client=self._make_mock_client((403, {}))) + result = searcher.search(page_index=1) + assert result.success is False + assert result.status_code == 403 + + def test_search_biz_error(self): + raw = {"code": 35, "message": "IP地址存在异常"} + searcher = SearchRecJobs(client=self._make_mock_client((200, raw))) + result = searcher.search(page_index=1) + assert result.success is False + + def test_search_builds_city_code_param(self): + mock_client = MagicMock() + mock_client.get.return_value = (200, {"code": 0, "zpData": {"jobList": [], "hasMore": False}}) + searcher = SearchRecJobs(city_code="101280600", page_size=10, client=mock_client) + searcher.search(page_index=2) + + # 验证 get 方法被调用 + assert mock_client.get.called + call_args = mock_client.get.call_args + # 第二个参数是 params dict + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("params") + assert params["cityCode"] == "101280600" + assert params["page"] == 2 + + +# ───────────────────────────────────────────────────────── +# 3. GetBrandDetail +# ───────────────────────────────────────────────────────── + +class TestGetBrandDetail: + + def test_fetch_success(self): + mock_client = MagicMock() + mock_client.get.return_value = (200, { + "code": 0, + "zpData": {"brandName": "测试公司", "brandId": "abc123"}, + }) + fetcher = GetBrandDetail(brand_id="abc123", client=mock_client) + result = fetcher.fetch() + + assert result.success is True + assert result.data["brandName"] == "测试公司" + + def test_fetch_404(self): + mock_client = MagicMock() + mock_client.get.return_value = (404, {}) + fetcher = GetBrandDetail(brand_id="notexist", client=mock_client) + result = fetcher.fetch() + + assert result.success is False + assert result.status_code == 404 + + +# ───────────────────────────────────────────────────────── +# 4. SearchBrandJobs +# ───────────────────────────────────────────────────────── + +class TestSearchBrandJobs: + + def test_search_success_has_more(self): + mock_client = MagicMock() + mock_client.get.return_value = (200, { + "code": 0, + "zpData": {"list": [{"jobName": "测试岗位"}], "hasMore": True}, + }) + searcher = SearchBrandJobs(brand_id="abc123", client=mock_client) + result = searcher.search(page_index=1) + + assert result.success is True + assert len(result.list) == 1 + assert result.is_end_page is False + + def test_search_success_no_more(self): + mock_client = MagicMock() + mock_client.get.return_value = (200, { + "code": 0, + "zpData": {"list": [], "hasMore": False}, + }) + searcher = SearchBrandJobs(brand_id="abc123", client=mock_client) + result = searcher.search(page_index=1) + + assert result.is_end_page is True + + +# ───────────────────────────────────────────────────────── +# 5. GetJobDetail(batch 接口路径) +# ───────────────────────────────────────────────────────── + +class TestGetJobDetail: + + def test_fetch_success_merges_sub_requests(self): + mock_client = MagicMock() + mock_client.batch.return_value = (200, { + "code": 0, + "zpData": { + "/wapi/zpgeek/miniapp/job/detail.json": { + "zpData": {"jobName": "数据工程师"} + }, + "/wapi/zpgeek/miniapp/jobdetail/improvement/query.json": { + "zpData": {"tags": ["Python", "大数据"]} + }, + }, + }) + fetcher = GetJobDetail(security_id="sid123", job_id="jid456", client=mock_client) + result = fetcher.fetch() + + assert result.success is True + assert result.data["detail"]["jobName"] == "数据工程师" + assert "Python" in result.data["improvement"]["tags"] + + def test_fetch_biz_error(self): + mock_client = MagicMock() + mock_client.batch.return_value = (200, {"code": 35, "message": "IP地址存在异常"}) + fetcher = GetJobDetail(security_id="sid", job_id="jid", client=mock_client) + result = fetcher.fetch() + assert result.success is False + + def test_fetch_exception_handled(self): + mock_client = MagicMock() + mock_client.batch.side_effect = ConnectionError("连接超时") + fetcher = GetJobDetail(security_id="sid", job_id="jid", client=mock_client) + result = fetcher.fetch() + assert result.success is False + assert "连接超时" in result.error + + +# ───────────────────────────────────────────────────────── +# 6. BossClient — Traceid/mpt/wt2 请求头注入 +# ───────────────────────────────────────────────────────── + +class TestBossClientHeaders: + + def test_get_injects_traceid(self): + """每次请求头包含 Traceid(以 M-W 开头)""" + client = BossClient() + headers = client._boss_headers() + assert "Traceid" in headers + assert headers["Traceid"].startswith("M-W") + + def test_traceid_length_is_valid(self): + """Traceid 格式长度正确(前缀 + 22 字符)""" + client = BossClient() + traceid = client._boss_headers()["Traceid"] + # 格式:M-W (3) + uuid_19 + checksum_3 = 25 + assert len(traceid) >= 20 + + def test_mpt_wt2_in_headers(self): + """signer 的 mpt/wt2 注入到请求头""" + from crawler_core.boss.sign import BossSign + signer = BossSign(mpt="test_mpt_value", wt2="test_wt2_value") + client = BossClient(signer=signer) + headers = client._boss_headers() + assert headers["mpt"] == "test_mpt_value" + assert headers["wt2"] == "test_wt2_value" + + def test_default_signer_empty_mpt_wt2(self): + """默认 signer(无登录)mpt/wt2 为空字符串""" + client = BossClient() + headers = client._boss_headers() + assert headers["mpt"] == "" + assert headers["wt2"] == ""