This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/tests/test_search_music.py
T
snowy-yang 65adfc9089 refactor: 删除 aiohttp_compat 垫片,music_api 改走原生 core.http_client
core/aiohttp_compat 是「同步转异步」迁移期的 requests 同形异步垫片:它存在的
唯一理由是让既有 `requests.post/get + raise_for_status()` 风格的代码以最小
改动迁到异步。其两个调用方(music_api/api.py、music_api/client.py)**本就已是
async def**——垫片只剩「保留 requests 语义」这一层包装,属于迁移残留。

改动:
- api.py:23 处 requests.post/get → await core.http_client.request()。
  该文件每处都在请求后立刻 raise_for_status() 才取 .json(),从不把 4xx 当数据读,
  故「_once 对非 2xx 直接抛 UpstreamHTTPError」与旧语义等价(原 raise_for_status
  抛的 HTTPError 也一律落入 RequestException 分支);
  except requests.RequestException → except UpstreamError。
- client.py:两个自管重试的方法改用新公开的 core.http_client.request_once()
  (单次、不重试),保留原有「5xx 退避重试一次 / 4xx 立即失败」的分流——
  把 status_code>=500 的判断换成 UpstreamHTTPError.status,行为逐条对齐
  (4xx=风控不重试,网络/5xx 重试一次)。响应体上限交由客户端 max_bytes 统一把关。
  post_request_full 返回类型改为 HTTPResult(qr.py 用到的 .text/.cookies/.headers
  三者在 HTTPResult 上齐备)。
- core/http_client:_once 更名并公开为 request_once(自管重试的调用方需要它)。
  注:这是为「删垫片」而补的唯一一个小口子,不是新增抽象。

注意一处**有意的行为变化**:api.py 原经垫片请求零重试,现在 request() 自带
「网络异常/5xx 退避重试一次、4xx 不重试」(core.http_client 既定口径)。
429/460 属 4xx 不重试,不加剧风控。

- 删除 core/aiohttp_compat.py;web/app.py 去掉其 close();pyproject 去掉其
  per-file-ignore;core/http_errors.py 注释更正为「仅供 core.sync_http」。
- 测试:test_aiohttp_compat.py 拆解为 test_async_bridge.py(只留桥的两条契约,
  垫片自身的 4 条随垫片消亡);test_search_music / test_playlist_detail 的替身
  边界从 api.requests.post 换成 api._http.request,异常类型换成 UpstreamNetworkError。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败;383=387-6+2,账面吻合)。
2026-09-28 19:17:03 +08:00

209 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""search_music 的行为契约测试(沙箱,不触真实上游)。
为什么:``music_api/api.py::search_music`` 是站内搜索的唯一上游通道
(101 行 / 四路条目映射 / 嵌套 7),此前**零测试覆盖**。它的错误映射
(RequestException/JSONDecodeError→APIException)与四路字段的容错口径
(残缺单条跳过、空 artists 不兜底、alias 缺省 '' 等)都是对外可观察行为,
拆解时改坏任意一路都不会有测试报警。
按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。
替身边界:monkeypatch ``music_api.api._http.request``(core.http_client 的进程级
客户端),响应替身实现 ``json`` 一个同步方法——``search_music`` 只用到它。
状态码错误不再由替身经 ``raise_for_status`` 模拟:请求入口自身即对非 2xx 抛
``UpstreamHTTPError``(见 core.http_client.request_once)。
"""
import json
from typing import Any
import pytest
from core.http_client import UpstreamNetworkError
from music_api import APIException
from music_api import api as api_mod
from music_api.api import NeteaseAPI
# 与生产一致的请求入口(搜索走 SEARCH_API)
_API = NeteaseAPI()
class _FakeResp:
"""请求响应替身(``HTTPResult`` 的形状子集):json 载荷或注入异常。"""
def __init__(self, payload: Any = None, *,
json_error: Exception | None = None):
self.payload = payload
self.json_error = json_error
def json(self) -> Any:
if self.json_error is not None:
raise self.json_error
return self.payload
@pytest.fixture()
def post_capture(monkeypatch):
"""替换请求入口,捕获调用参数并按用例注入响应。"""
holder: dict[str, Any] = {'resp': _FakeResp({'code': 200, 'result': {}})}
async def _fake_request(method: str, url: str, **kw: Any):
holder['method'] = method
holder['url'] = url
holder['data'] = kw.get('data')
return holder['resp']
monkeypatch.setattr(api_mod._http, 'request', _fake_request)
return holder
def _result(*items: dict[str, Any], key: str = 'songs') -> dict[str, Any]:
return {'code': 200, 'result': {key: list(items)}}
# ============ 四路映射 ============
async def test_search_songs_maps_fields(post_capture):
"""type=1:歌曲字段映射(ar 拼接、al 取名/封面、type 标记)。"""
post_capture['resp'] = _FakeResp(_result(
{'id': 1, 'name': '歌A', 'ar': [{'name': '歌手X'}, {'name': '歌手Y'}],
'al': {'name': '专辑Z', 'picUrl': 'https://pic/1.jpg'}},
{'id': 2, 'name': '歌B', 'ar': [], 'al': {}},
))
out = await _API.search_music('测试', {}, limit=30, search_type=1)
assert out == [
{'type': 'song', 'id': 1, 'name': '歌A', 'artists': '歌手X/歌手Y',
'album': '专辑Z', 'picUrl': 'https://pic/1.jpg'},
{'type': 'song', 'id': 2, 'name': '歌B', 'artists': '未知歌手',
'album': '未知专辑', 'picUrl': ''},
], out
# 请求形状:表单字段 + 分页透传
assert post_capture['data'] == {'s': '测试', 'type': 1, 'limit': 30, 'offset': 0}
async def test_search_songs_skips_items_without_id(post_capture):
"""上游单条残缺(无 id,下架/屏蔽):跳过该条,不拖死整页搜索。"""
post_capture['resp'] = _FakeResp(_result(
{'name': '残缺条目'}, # 无 id
{'id': 9, 'name': '正常'},
))
out = await _API.search_music('测试', {}, search_type=1)
assert [i['id'] for i in out] == [9]
async def test_search_albums_keeps_empty_artists(post_capture):
"""type=10:专辑 artists 为空时**不**兜底成"未知歌手"(与歌曲路不同口径)。"""
post_capture['resp'] = _FakeResp(_result(
{'id': 5, 'name': '专辑A', 'artists': [{'name': '歌手X'}],
'picUrl': 'https://pic/a.jpg'},
{'id': 6, 'name': '专辑B', 'artists': [], 'picUrl': None},
key='albums'))
out = await _API.search_music('测试', {}, search_type=10)
assert out == [
{'type': 'album', 'id': 5, 'name': '专辑A', 'artists': '歌手X',
'alias': '', 'album': '', 'picUrl': 'https://pic/a.jpg'},
{'type': 'album', 'id': 6, 'name': '专辑B', 'artists': '',
'alias': '', 'album': '', 'picUrl': None},
], out
async def test_search_artists_alias_join_and_default(post_capture):
"""type=100:alias 列表拼接;alias 缺失/为空 → ''(不是"未知"兜底)。"""
post_capture['resp'] = _FakeResp(_result(
{'id': 7, 'name': '歌手A', 'alias': ['A1', 'A2'], 'picUrl': 'https://pic/p.png'},
{'id': 8, 'name': '歌手B'},
key='artists'))
out = await _API.search_music('测试', {}, search_type=100)
assert out == [
{'type': 'artist', 'id': 7, 'name': '歌手A', 'artists': '',
'alias': 'A1/A2', 'album': '', 'picUrl': 'https://pic/p.png'},
{'type': 'artist', 'id': 8, 'name': '歌手B', 'artists': '',
'alias': '', 'album': '', 'picUrl': None},
], out
async def test_search_playlists_maps_creator_and_cover(post_capture):
"""type=1000:歌单取 creator.nickname / trackCount / coverImgUrl。"""
post_capture['resp'] = _FakeResp(_result(
{'id': 11, 'name': '歌单X', 'creator': {'nickname': '阿村'},
'trackCount': 42, 'coverImgUrl': 'https://cover/1.jpg'},
key='playlists'))
out = await _API.search_music('测试', {}, search_type=1000)
assert out == [{'type': 'playlist', 'id': 11, 'name': '歌单X', 'artists': '',
'creator': '阿村', 'trackCount': 42, 'album': '',
'picUrl': 'https://cover/1.jpg'}], out
async def test_unknown_search_type_returns_empty(post_capture):
"""未知 search_type:返回空列表,不抛(历史行为:前端枚举外类型静默)。"""
out = await _API.search_music('测试', {}, search_type=999)
assert out == []
async def test_missing_result_key_returns_empty(post_capture):
"""响应缺对应结果键:空列表,不抛 KeyError。"""
post_capture['resp'] = _FakeResp({'code': 200, 'result': {}})
out = await _API.search_music('测试', {}, search_type=1)
assert out == []
# ============ 错误映射 ============
async def test_non_200_code_raises_with_message(post_capture):
"""code!=200:APIException 透出上游 message;缺 message 用"未知错误"。"""
post_capture['resp'] = _FakeResp({'code': 400, 'message': '关键词太长'})
with pytest.raises(APIException, match=r'搜索失败: 关键词太长'):
await _API.search_music('测试', {}, search_type=1)
post_capture['resp'] = _FakeResp({'code': 400})
with pytest.raises(APIException, match=r'搜索失败: 未知错误'):
await _API.search_music('测试', {}, search_type=1)
async def test_non_dict_response_raises_format_error(post_capture):
"""响应是 null/数组(合法 JSON 非对象):明确的"响应格式异常"。"""
post_capture['resp'] = _FakeResp(None)
with pytest.raises(APIException, match='响应格式异常'):
await _API.search_music('测试', {}, search_type=1)
post_capture['resp'] = _FakeResp([{'code': 200}])
with pytest.raises(APIException, match='响应格式异常'):
await _API.search_music('测试', {}, search_type=1)
async def test_request_error_maps_to_api_exception(post_capture, monkeypatch):
"""网络层异常(core.http_client 的 UpstreamError 族):转"搜索请求失败"。
注意替身抛的必须是 ``UpstreamNetworkError``——真实链路里 aiohttp.ClientError
由 core.http_client 内部翻译后才冒出来,直接抛底层异常是不存在的形状。
"""
async def _boom(method: str, url: str, **kw: Any):
raise UpstreamNetworkError('connection reset')
monkeypatch.setattr(api_mod._http, 'request', _boom)
with pytest.raises(APIException, match='搜索请求失败'):
await _API.search_music('测试', {}, search_type=1)
async def test_json_decode_error_maps_to_api_exception(post_capture):
"""JSON 解析失败:转"解析搜索响应失败"。"""
bad = _FakeResp(None, json_error=json.JSONDecodeError('Expecting value', '<html>', 0))
post_capture['resp'] = bad
with pytest.raises(APIException, match='解析搜索响应失败'):
await _API.search_music('测试', {}, search_type=1)
# ============ 入参整形 ============
async def test_negative_offset_clamped_to_zero(post_capture):
"""offset 为负:夹到 0(max(0, int(offset or 0))),不透传负数。
(实现里还有 int(offset or 0) 对 None/字符串的防御,但签名声明 offset: int,
契约测试不为违反签名的形状背书。)
"""
await _API.search_music('测试', {}, search_type=1, offset=-5)
assert post_capture['data']['offset'] == 0