Archived
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,账面吻合)。
207 lines
8.2 KiB
Python
207 lines
8.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""get_playlist_detail 的行为契约测试(沙箱,不触真实上游)。
|
||
|
||
为什么:歌单详情是「歌单解析」功能的唯一上游通道(94 行 / 批次拉取 /
|
||
嵌套 3),此前零测试覆盖。它的容错口径都是对外可观察行为:
|
||
- 上游 code:200 + playlist:null →「歌单不存在或无权访问」(路由统一转 404)
|
||
- 明细批次业务失败计 failed_batches,不静默缺歌
|
||
- trackIds 混入脏 ID(如带圈数字——isdigit() 为真但 int() 抛错)跳过整批
|
||
- trackIds 数与实得数不一致时显式标 incomplete/missing
|
||
|
||
按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。
|
||
替身边界:monkeypatch ``music_api.api._http.request``(core.http_client 的进程级
|
||
客户端),按 URL 分流——PLAYLIST_DETAIL_API 与 SONG_DETAIL_V3 分别给响应替身。
|
||
"""
|
||
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
|
||
from music_api.constants import APIConstants
|
||
|
||
_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 api_capture(monkeypatch):
|
||
"""按 URL 分流的请求替身:歌单详情响应固定,批次响应按序消费。"""
|
||
calls: list[dict[str, Any]] = []
|
||
state: dict[str, Any] = {
|
||
'playlist_resp': _FakeResp({'code': 200, 'playlist': {}}),
|
||
'batch_resps': [_FakeResp({'code': 200, 'songs': []})],
|
||
}
|
||
|
||
async def _fake_request(method: str, url: str, **kw: Any):
|
||
calls.append({'url': url, 'data': kw.get('data')})
|
||
if url == APIConstants.PLAYLIST_DETAIL_API:
|
||
return state['playlist_resp']
|
||
assert url == APIConstants.SONG_DETAIL_V3, f'unexpected url: {url}'
|
||
if not state['batch_resps']:
|
||
raise AssertionError('批次响应耗尽')
|
||
return state['batch_resps'].pop(0)
|
||
|
||
monkeypatch.setattr(api_mod._http, 'request', _fake_request)
|
||
state['calls'] = calls
|
||
return state
|
||
|
||
|
||
def _playlist_payload(playlist: dict[str, Any]) -> _FakeResp:
|
||
return _FakeResp({'code': 200, 'playlist': playlist})
|
||
|
||
|
||
def _batch_c_payloads(calls: list[dict[str, Any]]) -> list[list[int]]:
|
||
"""从捕获的调用里提取每批的 c 载荷(id 列表)。"""
|
||
out = []
|
||
for c in calls:
|
||
if c['url'] == APIConstants.SONG_DETAIL_V3:
|
||
ids = [e['id'] for e in json.loads(c['data']['c'])]
|
||
out.append(ids)
|
||
return out
|
||
|
||
|
||
async def _detail(api_capture, playlist: dict[str, Any]) -> dict[str, Any]:
|
||
api_capture['playlist_resp'] = _playlist_payload(playlist)
|
||
return await _API.get_playlist_detail(123, {})
|
||
|
||
|
||
# ============ 歌单头 ============
|
||
|
||
async def test_playlist_null_is_apiexception(api_capture):
|
||
"""上游对不存在/私密歌单返回 code:200 + playlist:null → 显式 APIException。"""
|
||
api_capture['playlist_resp'] = _playlist_payload({})
|
||
with pytest.raises(APIException, match='歌单不存在或无权访问'):
|
||
await _API.get_playlist_detail(123, {})
|
||
|
||
|
||
async def test_business_code_error(api_capture):
|
||
api_capture['playlist_resp'] = _FakeResp({'code': 512, 'message': '需要登录'})
|
||
with pytest.raises(APIException, match='获取歌单详情失败: 需要登录'):
|
||
await _API.get_playlist_detail(123, {})
|
||
|
||
|
||
async def test_non_dict_response(api_capture):
|
||
api_capture['playlist_resp'] = _FakeResp(None)
|
||
with pytest.raises(APIException, match='响应格式异常'):
|
||
await _API.get_playlist_detail(123, {})
|
||
|
||
|
||
# ============ 成功主路径 ============
|
||
|
||
async def test_success_maps_tracks(api_capture):
|
||
api_capture['playlist_resp'] = _playlist_payload({
|
||
'id': 123, 'name': '歌单A', 'coverImgUrl': 'https://cover/1',
|
||
'creator': {'nickname': '作者'}, 'trackCount': 2,
|
||
'trackIds': [{'id': 1}, {'id': 2}, {'id': None}],
|
||
})
|
||
api_capture['batch_resps'] = [_FakeResp({'code': 200, 'songs': [
|
||
{'id': 1, 'name': '歌A', 'ar': [{'name': '歌手X'}],
|
||
'al': {'name': '专辑Z', 'picUrl': 'https://pic/1.jpg'}},
|
||
{'id': None, 'name': '残缺'},
|
||
{'id': 2, 'name': '歌B', 'ar': [], 'al': {}},
|
||
]})]
|
||
|
||
out = await _API.get_playlist_detail(123, {})
|
||
assert out['name'] == '歌单A'
|
||
assert out['creator'] == '作者'
|
||
assert out['tracks'] == [
|
||
{'id': 1, 'name': '歌A', 'artists': '歌手X',
|
||
'album': '专辑Z', 'picUrl': 'https://pic/1.jpg'},
|
||
{'id': 2, 'name': '歌B', 'artists': '未知歌手',
|
||
'album': '未知专辑', 'picUrl': ''},
|
||
]
|
||
assert 'incomplete' not in out
|
||
assert _batch_c_payloads(api_capture['calls']) == [[1, 2]] # 缺 id 的 trackId 不进批次
|
||
|
||
|
||
# ============ 批次容错 ============
|
||
|
||
async def test_batch_business_failure_marked_incomplete(api_capture):
|
||
"""明细批次业务失败:计 failed_batches,显式标 incomplete,不冒充完整。"""
|
||
api_capture['batch_resps'] = [_FakeResp({'code': 512, 'message': '爆了'})]
|
||
out = await _detail(api_capture, {'id': 1, 'trackIds': [{'id': 1}, {'id': 2}]})
|
||
assert out['tracks'] == []
|
||
assert out['incomplete'] is True
|
||
assert out['missing'] == 2
|
||
assert out['failed_batches'] == 1
|
||
|
||
|
||
async def test_batch_non_dict_counts_as_failed(api_capture):
|
||
"""批次响应体为 null(非 dict):按失败批次计,不让 AttributeError 失真。"""
|
||
api_capture['batch_resps'] = [_FakeResp(None)]
|
||
out = await _detail(api_capture, {'id': 1, 'trackIds': [{'id': 9}]})
|
||
assert out['incomplete'] is True
|
||
assert out['missing'] == 1
|
||
assert out['failed_batches'] == 1
|
||
|
||
|
||
async def test_dirty_trackid_skips_whole_batch_without_fail_count(api_capture):
|
||
"""脏 ID('①'.isdigit() 为真但 int() 抛 ValueError):跳过整批且不计失败批次。"""
|
||
api_capture['batch_resps'] = [] # 不应有批次请求发出
|
||
out = await _detail(api_capture, {'id': 1, 'trackIds': [{'id': '①'}]})
|
||
assert out['tracks'] == []
|
||
assert out['incomplete'] is True
|
||
assert out['missing'] == 1
|
||
assert out['failed_batches'] == 0
|
||
assert _batch_c_payloads(api_capture['calls']) == []
|
||
|
||
|
||
async def test_empty_trackids_no_batch_no_incomplete(api_capture):
|
||
"""trackIds 为空:不发生批次请求,结果完整。"""
|
||
out = await _detail(api_capture, {'id': 1, 'trackIds': []})
|
||
assert out['tracks'] == []
|
||
assert 'incomplete' not in out
|
||
assert _batch_c_payloads(api_capture['calls']) == []
|
||
|
||
|
||
async def test_batches_of_100(api_capture):
|
||
"""101 首分两批:首批 100、次批 1。"""
|
||
api_capture['batch_resps'] = [
|
||
_FakeResp({'code': 200, 'songs': []}),
|
||
_FakeResp({'code': 200, 'songs': []}),
|
||
]
|
||
track_ids = [{'id': i} for i in range(1, 102)]
|
||
out = await _detail(api_capture, {'id': 1, 'trackIds': track_ids})
|
||
payloads = _batch_c_payloads(api_capture['calls'])
|
||
assert len(payloads) == 2
|
||
assert len(payloads[0]) == 100
|
||
assert payloads[1] == [101]
|
||
assert out['incomplete'] is True # 明细一首未回,101 全缺
|
||
assert out['missing'] == 101
|
||
|
||
|
||
# ============ 错误映射 ============
|
||
|
||
async def test_network_error_maps_to_request_failed(api_capture, monkeypatch):
|
||
"""网络层异常(含批次阶段的):统一转「获取歌单详情请求失败」。"""
|
||
|
||
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.get_playlist_detail(123, {})
|
||
|
||
|
||
async def test_json_decode_error_maps_to_parse_failed(api_capture):
|
||
api_capture['playlist_resp'] = _FakeResp(json_error=json.JSONDecodeError('bad', 'x', 0))
|
||
with pytest.raises(APIException, match='解析歌单详情响应失败'):
|
||
await _API.get_playlist_detail(123, {})
|