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 133beab492 chore: 删除 HTTP 线三个垫片,同步/异步桥接层彻底清零
删 core/sync_http.py(requests 同形同步门面)、core/async_bridge.py(run_sync
同步↔异步桥)、core/http_errors.py(两垫片共用的 requests 同形异常层次),
以及 tests/test_async_bridge.py。

为什么必须一次删完:三者是**自闭环三角**——sync_http 是 async_bridge 与
http_errors 的唯一消费者,删掉任何一个其余两个立刻悬空。前序 B1 把 worker 变成
协程、B2 把 auth_system 迁到 aiosqlite、更早一轮把 music_api 改走
core.http_client 之后,这条链上的生产消费者已归零;http_errors 更是在 B2 之后
只剩 sync_http 一个使用者(此前 sync_http 的 docstring 还提到
models._RetryFromScratch 继承它的 RequestException——B1 已改为继承 Exception)。

删除前的核查(避免误删活代码):
- run_sync / get_loop / SyncResponse / Session( 全仓零残留引用(只剩
  aiohttp.ClientSession,那是真正的客户端,不受影响)。
- 所有 HTTPError 命中都是 core.http_client.UpstreamHTTPError——与
  core.http_errors 的 HTTPError **名字像但不是一个东西**,删除时别被 grep 骗。
- requests 早已不是声明依赖、全仓零导入;删掉 http_errors 后连 requests 同形
  异常的最后残影也没了。
- core/__init__.py 只有 docstring,无需改动;web/app.py 的 async_bridge.shutdown()
  已在 B1 移除。

顺带修正两处过时措辞:tests/test_search_music.py 的错误映射注释
RequestException → UpstreamError(异常层次已换);pyproject.toml 中 pyright
include 的理由注释 requests→sync_http → requests→aiohttp(去掉已删模块名、
保留原意)。auth_system/geo.py 的历史注释本就是过去时,保持不动。

门禁:ruff 全过 / pyright 0-0-0 / pytest 382 项全绿(0 失败 0 error 0 skipped)。
项数 384→382 是 test_async_bridge.py 两条用例**随被测对象一起删除**的预期减项,
不是跳过或漏跑。data/site.db 读写前后 sha256 一致(a19f5db…6487)。

至此「同步调异步 / 异步桥同步」两类垫片全部拆净:DB 线(B1 worker + B2
auth_system)、HTTP 线(本批)。生产代码里 to_thread 仅剩正当的阻塞卸载
(PBKDF2 / smtplib / 备份文件 IO / ZIP 组装 / rmtree),threading.Event 仅剩
取消与暂停的跨线程置位载体。
2026-09-28 20:18:08 +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),此前**零测试覆盖**。它的错误映射
(UpstreamError/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