# -*- coding: utf-8 -*- """MusicDownloader.download_music_file 的行为契约测试(沙箱,不触碰生产 data/)。 为什么:download_music_file 是**唯一**的音频落盘路径——单曲 worker 与音乐批量 ZIP 都经它,约 205 行 / 圈复杂度 47,此前**零直接测试覆盖**(test_zip_workers 里 只有一个 stub)。它同时承担缓存校验、断点续传、内容嗅探、体积上限、取消语义、 重试退避与元数据写入,任何一条被拆坏都不会有测试报警。 断言只依赖「调用后世界里发生了什么」——返回值、磁盘上的文件、发往网络的请求—— 不引用任何内部函数名,因此本文件可作为后续拆分的等价性验证器。 替身约定(均为模块级符号,替换点在 downloader 模块命名空间): - 网络:``_http``(core.http_client 的进程级异步客户端)——替身提供 ``async open(method, url, **kw)``,返回带 ``status``/``headers``/``content`` 的未关闭响应;分块读取走**真实的** ``iter_chunks``,顺带钉住「读取期异常 收敛为 UpstreamNetworkError」这条边界。 - aria2:``get_backend``(返回 None = 强制走内置 HTTP 通道) - 内容嗅探:``_sniff_audio_header``(默认放行,按需置 False) - 元数据写入:``MusicDownloader._write_metadata``(不真跑 mutagen) """ from __future__ import annotations import threading from pathlib import Path from typing import Any import aiohttp import pytest from music_downloader import DownloadCanceled, DownloadException, MusicInfo from music_downloader.downloader import MusicDownloader # ============ 替身 ============ class _FakeContent: """``aiohttp.ClientResponse.content`` 的替身。 chunks 保留原始 iterable(**不要** list() 归一化):生成器的副作用 (如中途置取消位)若在构造时刻就求值,「中途取消」会被悄悄测成 「开始前取消」——两条不同的路径混成一条,而测试仍是绿的。 元素约定:可调用对象 → 调用一次(注入副作用,如 cancel.set); 异常实例 → 抛出(模拟读取期断流);其余 → 作为分块产出。 """ def __init__(self, chunks): self._chunks = chunks async def iter_chunked(self, chunk_size=0): for item in self._chunks: if callable(item): item() continue if isinstance(item, BaseException): raise item yield item class _FakeResponse: """``core.http_client.open`` 返回的未关闭流式响应替身。""" def __init__(self, chunks, status=200, headers=None, reason=''): self.status = status self.reason = reason self.headers = dict(headers or {}) self.content = _FakeContent(chunks) self.closed = False def close(self): self.closed = True class _FakeClient: """记录每次 open(含 kw)并按序产出响应;responder 可随用例重装。""" def __init__(self): self._responder = None self.calls: list[tuple[str, dict[str, Any]]] = [] def set_responder(self, responder): self._responder = responder async def open(self, method, url, **kw): self.calls.append((url, kw)) assert self._responder is not None, '本用例未安装网络替身' return self._responder(len(self.calls), url, kw) def _body(size, status=200, content_range=None): """固定返回 size 字节的响应工厂(每次调用产出新响应对象)。""" def _mk(_attempt, _url, _kw): headers = {'Content-Range': content_range} if content_range else {} return _FakeResponse([b'x' * size], status=status, headers=headers) return _mk def _headers(kw) -> dict[str, str]: """取一次调用的请求头——没带 Range 时 downloader 传的是 None。""" return kw.get('headers') or {} def _info(mid=101, size=20, file_type='mp3'): return MusicInfo( id=mid, name='测试曲', artists='歌手', album='专辑', pic_url='', duration=180, track_number=1, download_url='http://cdn.test/a.mp3', file_type=file_type, file_size=size, quality='standard', ) def _paths(dl, info): """按生产同一函数推算落盘路径与 .part 路径(纯函数,可直接复用)。""" fp = dl._build_file_path(info) return fp, fp.with_name(fp.name + '.part') @pytest.fixture() def env(tmp_path, monkeypatch): """返回 (downloader, state, install)。 - state['sniff']:内容嗅探结果,默认 True - state['meta']:_write_metadata 的调用记录 [(文件路径, fields), ...] - install(responder) -> _FakeClient:安装网络替身,返回它以便断言 """ state: dict[str, Any] = {'sniff': True, 'meta': []} client = _FakeClient() def install(responder): client.set_responder(responder) return client async def _record_meta(self, file_path, music_info, fields=None): state['meta'].append((str(file_path), fields)) return True monkeypatch.setattr('music_downloader.downloader._http', client) monkeypatch.setattr('music_downloader.downloader.get_backend', lambda: None) monkeypatch.setattr('music_downloader.downloader._sniff_audio_header', lambda _p: state['sniff']) monkeypatch.setattr(MusicDownloader, '_write_metadata', _record_meta) install(lambda *a: pytest.fail('本用例不应发出网络请求')) dl = MusicDownloader(download_dir=str(tmp_path)) return dl, state, install # ============ 正常落盘 ============ async def test_success_promotes_part_to_final(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) install(_body(20)) r = await dl.download_music_file(101, music_info=info) assert r.success and r.error_message == '' assert Path(r.file_path or '') == fp assert r.file_size == 20 and fp.read_bytes() == b'x' * 20 assert not part.exists(), '完成后 .part 应改名转正' async def test_empty_chunks_skipped(env): dl, _state, install = env info = _info(size=20) fp, _ = _paths(dl, info) install(lambda a, u, kw: _FakeResponse([b'', b'x' * 20, b''])) seen: list[int] = [] r = await dl.download_music_file(101, music_info=info, progress_cb=lambda w, _t: seen.append(w)) assert r.success and fp.stat().st_size == 20 assert seen == [0, 20], '空分块不写盘也不触发进度回调' async def test_progress_reports_resume_start_then_each_chunk(env): dl, _state, install = env info = _info(size=20) install(lambda a, u, kw: _FakeResponse([b'a' * 8, b'b' * 12])) seen: list[tuple[int, int]] = [] r = await dl.download_music_file(101, music_info=info, progress_cb=lambda w, t: seen.append((w, t))) assert r.success assert seen == [(0, 20), (8, 20), (20, 20)], '起始回调带续传起点,此后每块一次' async def test_progress_callback_exception_never_breaks_download(env): dl, _state, install = env info = _info(size=20) install(_body(20)) def _boom(_w, _t): raise RuntimeError('回调炸了') r = await dl.download_music_file(101, music_info=info, progress_cb=_boom) assert r.success, '进度回调异常绝不影响下载主流程' async def test_music_info_fetched_when_not_provided(env, monkeypatch): dl, _state, install = env info = _info(size=20) seen: list[tuple] = [] async def _fake_info(mid, q, c=None): seen.append((mid, q)) return info monkeypatch.setattr(dl, 'get_music_info', _fake_info) install(_body(20)) r = await dl.download_music_file(101, 'lossless') assert r.success and seen == [(101, 'lossless')] # ============ 缓存校验 ============ async def test_cache_hit_skips_network(env): dl, _state, install = env info = _info(size=20) fp, _ = _paths(dl, info) fp.write_bytes(b'c' * 20) session = install(lambda *a: pytest.fail('缓存命中不应发网络请求')) r = await dl.download_music_file(101, music_info=info) assert r.success and r.file_size == 20 assert session.calls == [] async def test_cache_size_mismatch_triggers_redownload(env): dl, _state, install = env info = _info(size=20) fp, _ = _paths(dl, info) fp.write_bytes(b'c' * 15) # 与本次期望不符 = 缓存 miss session = install(_body(20)) r = await dl.download_music_file(101, music_info=info) assert r.success and fp.read_bytes() == b'x' * 20 assert len(session.calls) == 1, '坏缓存应被删除并重下,而不是当结果交付' async def test_zero_byte_cache_rejected_when_expected_size_unknown(env): dl, _state, install = env info = _info(size=0) fp, _ = _paths(dl, info) fp.write_bytes(b'') session = install(lambda a, u, kw: _FakeResponse([b'good'])) r = await dl.download_music_file(101, music_info=info) assert r.success and fp.read_bytes() == b'good' assert len(session.calls) == 1, '期望未知时至少拒绝 0 字节坏文件' # ============ 断点续传 ============ async def test_resume_sends_range_and_appends(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) session = install(lambda a, u, kw: _FakeResponse( [b'q' * 10], status=206, headers={'Content-Range': 'bytes 10-19/20'})) seen: list[tuple[int, int]] = [] r = await dl.download_music_file(101, music_info=info, progress_cb=lambda w, t: seen.append((w, t))) assert r.success assert _headers(session.calls[0][1]).get('Range') == 'bytes=10-' assert fp.read_bytes() == b'p' * 10 + b'q' * 10, '续传应追加而非覆盖' assert seen[0] == (10, 20), '起始进度应带续传起点' async def test_server_ignoring_range_rewrites_from_zero(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) install(lambda a, u, kw: _FakeResponse([b'r' * 20], status=200)) r = await dl.download_music_file(101, music_info=info) assert r.success and fp.read_bytes() == b'r' * 20, '服务器返回 200 须从头重写' async def test_complete_part_promoted_without_network(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) part.write_bytes(b'z' * 20) # 已下完但未改名 session = install(lambda *a: pytest.fail('完整残片不应发网络请求')) r = await dl.download_music_file(101, music_info=info) assert r.success and r.file_size == 20 assert session.calls == [] assert fp.read_bytes() == b'z' * 20 and not part.exists() async def test_oversized_part_discarded_starts_from_zero(env): dl, _state, install = env info = _info(size=20) _fp, part = _paths(dl, info) part.write_bytes(b'o' * 30) # 超尺寸残片没有续传价值 session = install(_body(20)) r = await dl.download_music_file(101, music_info=info) assert r.success assert 'Range' not in _headers(session.calls[0][1]) async def test_zero_byte_part_discarded(env): dl, _state, install = env info = _info(size=20) _fp, part = _paths(dl, info) part.write_bytes(b'') session = install(_body(20)) r = await dl.download_music_file(101, music_info=info) assert r.success and 'Range' not in _headers(session.calls[0][1]) async def test_content_range_offset_mismatch_restarts_from_scratch(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) def responder(n, _url, _kw): if n == 1: return _FakeResponse([b'q' * 5], status=206, headers={'Content-Range': 'bytes 3-7/20'}) return _FakeResponse([b'f' * 20], status=200) session = install(responder) r = await dl.download_music_file(101, music_info=info, retries=1) assert r.success and len(session.calls) == 2 assert 'Range' not in _headers(session.calls[1][1]), '清残片后应从头下' assert fp.read_bytes() == b'f' * 20 async def test_206_without_content_range_restarts_from_scratch(env): dl, _state, install = env info = _info(size=20) _fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) def responder(n, _url, _kw): if n == 1: return _FakeResponse([b'q' * 5], status=206) # 头被中间层剥离 return _FakeResponse([b'f' * 20], status=200) session = install(responder) r = await dl.download_music_file(101, music_info=info, retries=1) assert r.success and len(session.calls) == 2 assert 'Range' not in _headers(session.calls[1][1]) async def test_http_416_restarts_from_scratch(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) def responder(n, _url, _kw): if n == 1: return _FakeResponse([], status=416) return _FakeResponse([b'f' * 20], status=200) session = install(responder) r = await dl.download_music_file(101, music_info=info, retries=1) assert r.success and len(session.calls) == 2 assert 'Range' not in _headers(session.calls[1][1]) assert fp.read_bytes() == b'f' * 20 # ============ 失败与重试 ============ async def test_cdn_4xx_is_deterministic_and_not_retried(env): dl, _state, install = env info = _info(size=20) session = install(lambda a, u, kw: _FakeResponse([], status=403)) with pytest.raises(DownloadException) as ei: await dl.download_music_file(101, music_info=info, retries=2) assert 'HTTP 403' in str(ei.value) assert len(session.calls) == 1, '确定性失败对同一失效直链白重试毫无意义' async def test_408_is_retried_then_reported_as_failure(env): dl, _state, install = env info = _info(size=20) session = install(lambda a, u, kw: _FakeResponse([], status=408)) r = await dl.download_music_file(101, music_info=info, retries=1) assert not r.success assert '下载请求失败' in r.error_message assert len(session.calls) == 2, '408 请求超时应保留可重试' async def test_5xx_is_retried_and_keeps_part(env): """5xx 是服务端瞬时故障(非确定性失败):应可重试,且保留 .part 供续传。 与 4xx 的分界在这里钉住:error 分类只看 400<=status<500,5xx 落回裸重抛, 由外层 UpstreamError 分支退避重试。此前这条路径无测试覆盖。 """ dl, _state, install = env info = _info(size=100) fp, part = _paths(dl, info) part.write_bytes(b'p' * 10) session = install(lambda a, u, kw: _FakeResponse([], status=500)) r = await dl.download_music_file(101, music_info=info, retries=1) assert not r.success and '下载请求失败' in r.error_message assert len(session.calls) == 2, '5xx 非确定性失败,应重试' assert not fp.exists(), '未交付不得留下最终文件' assert part.stat().st_size == 10, '.part 应保留供下轮续传' async def test_incomplete_size_keeps_part_and_reports(env): dl, _state, install = env info = _info(size=100) fp, part = _paths(dl, info) install(_body(10)) r = await dl.download_music_file(101, music_info=info, retries=0) assert not r.success and '下载不完整' in r.error_message assert part.exists() and part.stat().st_size == 10, '.part 应保留以供续传' assert not fp.exists(), '不完整的下载不得转正交付' async def test_incomplete_then_resumes_on_next_attempt(env): dl, _state, install = env info = _info(size=100) _fp, part = _paths(dl, info) def responder(n, _url, _kw): if n == 1: return _FakeResponse([b'a' * 10], status=200) return _FakeResponse([b'b' * 5], status=206, headers={'Content-Range': 'bytes 10-14/100'}) session = install(responder) r = await dl.download_music_file(101, music_info=info, retries=1) assert not r.success and '下载不完整' in r.error_message assert _headers(session.calls[1][1]).get('Range') == 'bytes=10-', '下轮应续传' assert part.stat().st_size == 15 async def test_network_error_keeps_part_for_resume(env): """读取期断流:真实的 iter_chunks 把 aiohttp.ClientError 收敛成 UpstreamNetworkError,外层据此保留 .part 供续传。""" dl, _state, install = env info = _info(size=100) fp, part = _paths(dl, info) install(lambda a, u, kw: _FakeResponse( [b'a' * 10, aiohttp.ClientPayloadError('连接中断')])) r = await dl.download_music_file(101, music_info=info, retries=0) assert not r.success and '下载请求失败' in r.error_message assert part.exists() and part.stat().st_size == 10, '网络错误应保留 .part' assert not fp.exists() # ============ 内容校验与体积上限 ============ async def test_unknown_size_non_audio_content_rejected(env): dl, state, install = env info = _info(size=0) fp, part = _paths(dl, info) state['sniff'] = False install(lambda a, u, kw: _FakeResponse([b'blocked'])) with pytest.raises(DownloadException) as ei: await dl.download_music_file(101, music_info=info) assert '非音频数据' in str(ei.value) assert not part.exists() and not fp.exists() async def test_unknown_size_audio_content_accepted(env): dl, state, install = env info = _info(size=0) fp, _ = _paths(dl, info) state['sniff'] = True install(lambda a, u, kw: _FakeResponse([b'ID3audio'])) r = await dl.download_music_file(101, music_info=info) assert r.success and fp.read_bytes() == b'ID3audio' async def test_size_limit_discards_part_and_fails(env, monkeypatch): dl, _state, install = env monkeypatch.setattr('music_downloader.downloader._MAX_DOWNLOAD_BYTES', 16) info = _info(size=0) fp, part = _paths(dl, info) install(lambda a, u, kw: _FakeResponse([b'x' * 100])) with pytest.raises(DownloadException) as ei: await dl.download_music_file(101, music_info=info) assert '大小上限' in str(ei.value) assert not part.exists() and not fp.exists(), '超限残片无续传价值,须清理' # ============ 取消 ============ async def test_cancel_before_transfer_clears_part_and_skips_network(env): dl, _state, install = env info = _info(size=20) _fp, part = _paths(dl, info) part.write_bytes(b'p' * 5) cancel = threading.Event() cancel.set() session = install(lambda *a: pytest.fail('已取消不应发网络请求')) with pytest.raises(DownloadCanceled): await dl.download_music_file(101, music_info=info, cancel_event=cancel) assert session.calls == [] assert not part.exists(), '取消须清掉 .part' async def test_cancel_midway_clears_half_written_file(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) cancel = threading.Event() # 第二块产出**之前**置取消位:取消发生在迭代过程中(.part 已建立) install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, b'b' * 15])) with pytest.raises(DownloadCanceled): await dl.download_music_file(101, music_info=info, cancel_event=cancel) assert not part.exists() and not fp.exists() async def test_network_error_while_canceled_reports_canceled(env): dl, _state, install = env info = _info(size=20) fp, part = _paths(dl, info) cancel = threading.Event() install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, OSError('句柄异常')])) with pytest.raises(DownloadCanceled): await dl.download_music_file(101, music_info=info, cancel_event=cancel, retries=2) assert not part.exists() and not fp.exists(), '已取消时残片没有续传价值' async def test_cancel_after_promote_keeps_file_but_skips_metadata(env): dl, state, install = env info = _info(size=20) fp, part = _paths(dl, info) cancel = threading.Event() def cb(written, _total): if written >= 20: cancel.set() install(_body(20)) with pytest.raises(DownloadCanceled): await dl.download_music_file(101, music_info=info, cancel_event=cancel, progress_cb=cb) assert fp.exists() and not part.exists(), '已转正的文件不回滚' assert state['meta'] == [], '取消后不应再写元数据' # ============ 元数据 ============ async def test_metadata_written_only_when_requested(env): dl, state, install = env install(_body(20)) await dl.download_music_file(101, music_info=_info(mid=101), write_meta=False) assert state['meta'] == [], 'write_meta=False 不应写元数据' r = await dl.download_music_file(102, music_info=_info(mid=102), write_meta=True, meta_fields=['title']) assert r.success assert [f for _p, f in state['meta']] == [['title']], 'fields 应原样透传'