"""穿山甲 GroMore 数据 API 客户端测试。 重点锚定**签名算法**:用官方文档 Python / Java 示例里给出的「参数 + secure_key → sign」两个 测试向量做断言,保证我们的 build_sign 与穿山甲服务端逐字节一致(签错=接口直接 101 验签失败)。 另测拉取的翻页、业务码非 100 抛错、响应解析。 """ from __future__ import annotations import pytest from app.core.config import settings from app.integrations import pangle_report from app.integrations.pangle_report import PangleReportError, build_sign # ---- 签名:官方文档测试向量(完整 key 与期望 sign 来自文档代码注释)---- def test_build_sign_matches_doc_python_vector(): params = { "current_time": "2022-01-06 16:10:54", "sign_type": "MD5", "version": "2.0", "user_id": 459, "role_id": 459, } sign = build_sign(params, "7f2ddb34d999ea61c00246ea3971529c") assert sign == "57b9fed9b6c09cefdced7fa770fe341d" def test_build_sign_matches_doc_java_vector(): params = { "user_id": 459, "role_id": 459, "version": "2.0", "sign_type": "MD5", "current_time": "2022-01-06 16:05:49", } sign = build_sign(params, "1c589c70f7bb2746027ce90c33d2544c") assert sign == "4d47b3a7679448c3da5c1d05e5291533" def test_build_sign_drops_sign_and_empty_fields(): """sign 字段与空值字段不参与签名:带上它们应得到与不带时相同的 sign。""" base = {"version": "2.0", "user_id": 459, "role_id": 459, "sign_type": "MD5"} with_noise = {**base, "sign": "deadbeef", "code_ids": "", "os": None} assert build_sign(with_noise, "key123") == build_sign(base, "key123") # ---- 拉取:伪造 httpx 客户端,验证翻页 / 解析 / 业务码 ---- class _FakeResp: def __init__(self, payload: dict): self._payload = payload def raise_for_status(self) -> None: pass def json(self) -> dict: return self._payload class _FakeClient: """按 offset 返回分页响应;记录每次请求的 params 供断言。""" def __init__(self, pages: list[dict]): self._pages = pages self.calls: list[dict] = [] def __enter__(self): return self def __exit__(self, *a): return False def get(self, url: str, params: dict): self.calls.append(params) # 第 N 次调用取第 N 页(has_next 驱动翻页);超出用最后一页兜底 page = self._pages[min(len(self.calls) - 1, len(self._pages) - 1)] return _FakeResp(page) @pytest.fixture def _configured(monkeypatch): monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 459) monkeypatch.setattr(settings, "PANGLE_REPORT_ROLE_ID", 459) monkeypatch.setattr(settings, "PANGLE_REPORT_SECURITY_KEY", "key123") def test_fetch_daily_report_paginates_and_parses(monkeypatch, _configured): page1 = { "code": "100", "message": "", "data": { "has_next": "1", "currency": "cny", "total": 3, "report_list": [ {"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227", "revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537"}, ], }, } page2 = { "code": "100", "message": "", "data": { "has_next": "0", "currency": "cny", "total": 3, "report_list": [ {"start_date": "2026-06-27", "site_id": "5832303", "ad_unit_id": "104137445", "revenue": "0.00", "api_revenue": "", "ecpm": "0", "imp_cnt": "12"}, ], }, } fake = _FakeClient([page1, page2]) monkeypatch.setattr(pangle_report.httpx, "Client", lambda *a, **k: fake) rows = pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27") assert len(rows) == 2 # 两页都被取到(has_next 驱动翻页) assert rows[0]["ad_unit_id"] == "104142227" assert rows[1]["api_revenue"] == "" # 每次请求都带上签名与必填鉴权参数 assert "sign" in fake.calls[0] assert fake.calls[0]["user_id"] == 459 and fake.calls[0]["version"] == "2.0" def test_fetch_daily_report_raises_on_business_error(monkeypatch, _configured): fail = {"code": "118", "message": "no revenue permission", "data": {}} monkeypatch.setattr( pangle_report.httpx, "Client", lambda *a, **k: _FakeClient([fail]) ) with pytest.raises(PangleReportError): pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27") def test_fetch_daily_report_no_config_raises(monkeypatch): monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 0) with pytest.raises(PangleReportError): pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")