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_playlist_detail.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

207 lines
8.2 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 -*-
"""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, {})