# -*- coding: utf-8 -*- """播客单集下载 worker 的行为契约测试(沙箱,不触碰生产 data/)。 为什么:``services/podcast.py::_podcast_download_worker`` 是播客单集下载的**唯一** 后台执行路径(解析 → 音质降级直链 → 流式下载 → 可选 ZIP → /download/result 交付), 此前**零直接测试覆盖**(test_podcast_stream.py 只覆盖到流式下载这一层)。它有三条 except 分支各写一遍「结算 + 删任务目录」,正文里散着三处取消检查——改坏任意一处 都不会有测试报警。 按惯例:**先在未改动的原始实现上跑绿**,这批测试因此忠实编码了旧行为,重构时本身 即等价性验证器。断言只依赖「调用 worker 后世界里发生了什么」(任务终态、磁盘上的 文件、ZIP 内容、账本行、余额),不引用 worker 内部函数名。 替身边界(与真实契约一一对应): - ``podcast.get_program_detail`` / ``podcast.resolve_episode_url``:可编程节目信息与直链; - ``_podcast_stream_download``:契约是「成功写 target 并返回字节数;取消抛 DownloadCanceled;失败抛 DownloadException」,替身照此实现,不模拟字节流; - ``fetch_cover_bytes``:失败返回 None(真实契约内部吞异常),不抛。 账本口径:一律用 ``reserve_and_open`` 真预扣(与 test_single_worker.py 同理由)。 """ import asyncio import threading import time import zipfile from pathlib import Path from typing import Any import pytest import auth_system as auth import core.db as core_db from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK from music_downloader import DownloadCanceled, DownloadException from services import podcast as podcast_svc from services.api_service import api_service PW = 'Passw0rd12' CREDITS = 50 # ============ 测试替身 ============ class _NullLogger: """吞掉 api_service.logger 输出,避免测试往项目根 music_api.log 追加。""" def debug(self, *a: Any, **k: Any) -> None: ... def info(self, *a: Any, **k: Any) -> None: ... def warning(self, *a: Any, **k: Any) -> None: ... def error(self, *a: Any, **k: Any) -> None: ... def _program(**over: Any) -> dict[str, Any]: """标准节目详情替身(字段与 music_api.podcast.get_program_detail 对齐)。""" d: dict[str, Any] = { 'main_song_id': 999, 'name': '第一集 测试', 'serial_num': 3, 'cover_url': 'https://cover.example/x.jpg', 'radio_name': '测试电台', 'dj_name': '主播甲', 'duration_text': '10:00', } d.update(over) return d def _url_info(**over: Any) -> dict[str, Any]: d: dict[str, Any] = {'url': 'https://cdn.example/a.mp3', 'type': 'mp3', 'size': 11, 'level': 'standard'} d.update(over) return d class _FakeStream: """``_podcast_stream_download`` 替身。 ``on_call`` 在写入 target 之前被调用(注入取消置位 / 外部终态 / 抛错); ``fail`` 模拟流式下载失败(如完整性不达标)。 """ def __init__(self, *, fail: Exception | None = None, on_call: Any = None): self.fail = fail self.on_call = on_call self.calls: list[dict[str, Any]] = [] async def __call__(self, url: str, target: Path, total_size: int, cancel_ev: threading.Event, task_id: str = '', progress_label: str = '') -> int: self.calls.append({'url': url, 'target': Path(target), 'total_size': total_size}) if self.on_call is not None: self.on_call() if cancel_ev.is_set(): raise DownloadCanceled('任务已取消') if self.fail is not None: raise self.fail Path(target).write_bytes(b'audio-bytes') return 11 # ============ 沙箱 ============ @pytest.fixture() def sandbox(tmp_path, monkeypatch): """临时站点库 + 临时下载目录 + 空任务表 + 播客上游替身。""" db_path = tmp_path / 'site.db' monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) monkeypatch.setattr(auth.db, 'DB_PATH', db_path) monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', db_path) asyncio.run(auth.init_db()) asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS)) downloads = tmp_path / 'downloads' downloads.mkdir() monkeypatch.setattr(api_service, 'downloads_path', downloads) monkeypatch.setattr(api_service, 'logger', _NullLogger()) # 操作记录写进程内共享列表——隔离掉,断言改由个例捕获 monkeypatch.setattr(podcast_svc, '_record_log', lambda *a: None) with DOWNLOAD_TASKS_LOCK: DOWNLOAD_TASKS.clear() yield {'root': tmp_path, 'downloads': downloads} with DOWNLOAD_TASKS_LOCK: DOWNLOAD_TASKS.clear() # ============ 工具 ============ def _uid(username: str = 'payer') -> int: u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['id']) def _balance(username: str = 'payer') -> int: u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['duration_seconds'] or 0) def _status(task_id: str) -> str: with DOWNLOAD_TASKS_LOCK: return str(DOWNLOAD_TASKS.get(task_id, {}).get('status') or '') def _register_podcast(sandbox, *, program_id: int = 777, user_id: int, reserved: int = 1, **params: Any) -> tuple[str, dict[str, Any]]: tid = 'p' + str(int(time.time() * 1000000) % 10 ** 10) left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid)) assert left is not None, '预扣失败(余额不足,检查 fixture 的 credits)' task: dict[str, Any] = { 'type': 'single', 'task_id': tid, 'user_id': user_id, 'billing_user_id': user_id, 'billing_reserved': reserved, 'total': 1, 'done': 0, 'failed': 0, 'status': 'queued', 'start': time.time(), 'msg': '排队中...', 'cancel': threading.Event(), 'pause': threading.Event(), 'device_id': 'dev-test', 'result': None, 'params': { 'program_id': program_id, 'quality': 'standard', 'need_zip': False, 'cookies': {}, 'remote_ip': '1.2.3.4', **params, }, } with DOWNLOAD_TASKS_LOCK: DOWNLOAD_TASKS[tid] = task return tid, task def _install_upstream(monkeypatch, *, program: dict[str, Any] | None = None, url_info: dict[str, Any] | None = None, stream: Any = None, cover: Any = None) -> None: """装上播客上游替身(节目详情 / 直链 / 流式下载 / 封面)。""" async def _detail(pid: Any, cookies: Any) -> dict[str, Any]: return program if program is not None else _program() async def _resolve(sid: Any, cookies: Any, quality: str) -> dict[str, Any]: return url_info if url_info is not None else _url_info() monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail', _detail) monkeypatch.setattr(podcast_svc.podcast, 'resolve_episode_url', _resolve) if cover is None: async def _default_cover(_url: str) -> bytes: return b'\xff\xd8\xff cover' cover = _default_cover if stream is None: stream = _FakeStream() monkeypatch.setattr(podcast_svc, '_podcast_stream_download', stream) monkeypatch.setattr(podcast_svc, 'fetch_cover_bytes', cover) def _run(task_id: str) -> threading.Thread: """把 worker 协程跑在独立线程的私有事件循环里(等价于生产的任务循环)。""" def _target() -> None: asyncio.run(podcast_svc._podcast_download_worker(task_id)) th = threading.Thread(target=_target, daemon=True) th.start() return th def _zip_names(path: str) -> list[str]: with zipfile.ZipFile(path) as zf: return zf.namelist() # ============ 正常路径 ============ def test_podcast_download_completes(sandbox, monkeypatch): """常规单集(不打包):终态 done、音频落在任务目录、账本按交付结算、余额 -1。""" uid = _uid() before = _balance() _install_upstream(monkeypatch) tid, task = _register_podcast(sandbox, program_id=777, user_id=uid) th = _run(tid) th.join(timeout=30) assert not th.is_alive(), 'worker 未在 30s 内结束' assert _status(tid) == 'done', f"终态={_status(tid)} msg={task.get('msg')}" assert task['msg'] == '下载完成' assert task['done'] == 1 and task['completed_at'] > 0 res = task['result'] assert res is not None # 交付名干净(不带节目 ID),磁盘名带 [节目ID] assert res['filename'] == '003 - 第一集 测试.mp3' assert res['mimetype'] == 'audio/mp3' assert res['audio_files'] == [res['file_path']] assert 'zip_path' not in res delivered = Path(res['file_path']) assert delivered.exists() assert delivered.name == '003 - 第一集 测试 [777].mp3' assert delivered.parent == sandbox['downloads'] / 'tasks' / tid assert delivered.read_bytes() == b'audio-bytes' row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] != 'open' assert row['delivered'] == 1 assert _balance() == before - 1, '交付成功:只扣预扣的 1 次,无退款' def test_no_serial_prefix_when_serial_zero(sandbox, monkeypatch): """serial_num 缺失/为 0:文件名不带序号前缀,直接用单集名。""" uid = _uid() _install_upstream(monkeypatch, program=_program(serial_num=0)) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done' assert task['result']['filename'] == '第一集 测试.mp3' def test_ext_follows_url_info_type(sandbox, monkeypatch): """直链 type=m4a:交付名/磁盘名扩展名随之变化。""" uid = _uid() _install_upstream(monkeypatch, url_info=_url_info(type='m4a')) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done' res = task['result'] assert res['filename'] == '003 - 第一集 测试.m4a' assert res['mimetype'] == 'audio/m4a' def test_record_log_gets_episode_name(sandbox, monkeypatch): """成功交付后按单集名记一条操作记录(IP 取 params.remote_ip)。""" uid = _uid() logs: list[tuple] = [] _install_upstream(monkeypatch) monkeypatch.setattr(podcast_svc, '_record_log', lambda *a: logs.append(a)) tid, _ = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert logs == [('下载', '1.2.3.4', '[播客] 第一集 测试')], logs def test_remote_ip_defaults_when_missing(sandbox, monkeypatch): """params 无 remote_ip:按 '-' 记录,不因缺字段报错。""" uid = _uid() logs: list[tuple] = [] _install_upstream(monkeypatch) monkeypatch.setattr(podcast_svc, '_record_log', lambda *a: logs.append(a)) tid, task = _register_podcast(sandbox, user_id=uid) del task['params']['remote_ip'] th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done' assert logs == [('下载', '-', '[播客] 第一集 测试')], logs def test_zip_contains_audio_cover_and_info(sandbox, monkeypatch): """need_zip=true:包内含音频 + 封面 + info.txt,result 走 zip 交付形状。""" uid = _uid() _install_upstream(monkeypatch) tid, task = _register_podcast(sandbox, user_id=uid, need_zip=True) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done', f"终态={_status(tid)} msg={task.get('msg')}" res = task['result'] assert res is not None assert res['filename'] == '003 - 第一集 测试.zip' assert res['mimetype'] == 'application/zip' assert sorted(_zip_names(res['zip_path'])) == [ '003 - 第一集 测试.jpg', '003 - 第一集 测试.mp3', 'info.txt'] with zipfile.ZipFile(res['zip_path']) as zf: info = zf.read('info.txt').decode('utf-8') assert '单集: 第一集 测试' in info assert '播客: 测试电台' in info assert '主播: 主播甲' in info assert '时长: 10:00' in info assert '音质: ' in info # 音频正文同时留在任务目录(result.file_path 指向它) assert Path(res['file_path']).exists() def test_zip_without_cover_when_cover_url_empty(sandbox, monkeypatch): """节目无 cover_url:ZIP 不含封面条目,其余照常(封面非交付必要条件)。""" uid = _uid() _install_upstream(monkeypatch, program=_program(cover_url='')) tid, task = _register_podcast(sandbox, user_id=uid, need_zip=True) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done' names = _zip_names(task['result']['zip_path']) assert not [n for n in names if n.endswith('.jpg')], names assert [n for n in names if n.endswith('.mp3')], names def test_cover_fetch_failure_still_delivers_zip(sandbox, monkeypatch): """取封面抛异常:被吞掉,ZIP 照常交付(历史行为:封面失败不炸任务)。""" uid = _uid() async def _boom(url: str) -> bytes: raise RuntimeError('cover cdn down') _install_upstream(monkeypatch, cover=_boom) tid, task = _register_podcast(sandbox, user_id=uid, need_zip=True) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done', f"终态={_status(tid)} msg={task.get('msg')}" names = _zip_names(task['result']['zip_path']) assert not [n for n in names if n.endswith('.jpg')], names # ============ 解析失败 ============ def test_missing_song_id_marks_error_and_refunds(sandbox, monkeypatch): """单集缺 main_song_id:转 error(文案透出)、退费、任务目录清理。""" uid = _uid() before = _balance() _install_upstream(monkeypatch, program=_program(main_song_id=None)) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'error', f"终态={_status(tid)} msg={task.get('msg')}" assert task['msg'] == '该单集缺少音频信息(mainSong)' assert task['result'] is None assert _balance() == before, '失败应全额退回预扣' row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 and row['status'] != 'open' assert not (sandbox['downloads'] / 'tasks' / tid).exists(), '失败应清掉任务目录' def test_no_url_marks_error_and_refunds(sandbox, monkeypatch): """直链解析为空(下架/版权):转 error、退费、目录清理。""" uid = _uid() before = _balance() _install_upstream(monkeypatch, url_info=_url_info(url='')) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'error' assert task['msg'] == '未获取到音频直链(单集可能已下架或受版权限制)' assert _balance() == before assert not (sandbox['downloads'] / 'tasks' / tid).exists() def test_stream_failure_marks_error_and_refunds(sandbox, monkeypatch): """流式下载失败(如完整性不达标):转 error、退费、目录清理。""" uid = _uid() before = _balance() _install_upstream(monkeypatch, stream=_FakeStream(fail=DownloadException('下载不完整(5/11 字节)'))) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'error' assert task['msg'] == '下载不完整(5/11 字节)' assert _balance() == before assert not (sandbox['downloads'] / 'tasks' / tid).exists() def test_unexpected_exception_uses_generic_message(sandbox, monkeypatch): """非预期异常:用户可见文案泛化(不泄上游细节),原文进日志、余额退回。""" uid = _uid() before = _balance() async def _boom(pid: Any, cookies: Any) -> dict[str, Any]: raise RuntimeError('upstream host=10.0.0.9 refused') _install_upstream(monkeypatch) monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail', _boom) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'error', f"终态={_status(tid)}" assert task['msg'] == '下载失败,请稍后重试' assert '10.0.0.9' not in task['msg'], '上游细节不得出现在用户可见文案里' assert _balance() == before assert not (sandbox['downloads'] / 'tasks' / tid).exists() # ============ 取消 ============ def test_cancel_before_stream_start(sandbox, monkeypatch): """流式下载启动前取消已置位:canceled、退费、目录清理。""" uid = _uid() before = _balance() holder: dict[str, Any] = {} def _cancel_first(): holder['task']['cancel'].set() _install_upstream(monkeypatch, stream=_FakeStream(on_call=_cancel_first)) tid, task = _register_podcast(sandbox, user_id=uid) holder['task'] = task th = _run(tid) th.join(timeout=30) assert _status(tid) == 'canceled', f"终态={_status(tid)} msg={task.get('msg')}" assert task['result'] is None assert _balance() == before, '取消应全额退回预扣' assert not (sandbox['downloads'] / 'tasks' / tid).exists() def test_cancel_after_download_before_publish(sandbox, monkeypatch): """下载已落盘但取消随即置位:不发布交付,转 canceled 并退费。""" uid = _uid() before = _balance() holder: dict[str, Any] = {} def _cancel_after(): threading.Timer(0.0, lambda: holder['task']['cancel'].set()).start() _install_upstream(monkeypatch, stream=_FakeStream(on_call=_cancel_after)) tid, task = _register_podcast(sandbox, user_id=uid) holder['task'] = task th = _run(tid) th.join(timeout=30) assert _status(tid) == 'canceled', f"终态={_status(tid)} msg={task.get('msg')}" assert task['result'] is None, '取消后不得发布交付' assert _balance() == before assert not (sandbox['downloads'] / 'tasks' / tid).exists() def test_cancel_during_zip_stage(sandbox, monkeypatch): """打包阶段取消置位(封面回调里触发):ZIP 不发布、canceled、退费。""" uid = _uid() before = _balance() holder: dict[str, Any] = {} async def _cancel_in_cover(url: str) -> bytes: holder['task']['cancel'].set() return b'\xff\xd8\xff cover' _install_upstream(monkeypatch, cover=_cancel_in_cover) tid, task = _register_podcast(sandbox, user_id=uid, need_zip=True) holder['task'] = task th = _run(tid) th.join(timeout=30) assert _status(tid) == 'canceled', f"终态={_status(tid)} msg={task.get('msg')}" assert task['result'] is None assert _balance() == before assert not (sandbox['downloads'] / 'tasks' / tid).exists() def test_terminal_state_not_overwritten_by_late_success(sandbox, monkeypatch): """任务已被外部置终态(canceled)时,worker 的成功路径不得覆盖为 done。""" uid = _uid() before = _balance() holder: dict[str, Any] = {} def _mark_canceled(): with DOWNLOAD_TASKS_LOCK: holder['task']['status'] = 'canceled' holder['task']['completed_at'] = time.time() _install_upstream(monkeypatch, stream=_FakeStream(on_call=_mark_canceled)) tid, task = _register_podcast(sandbox, user_id=uid) holder['task'] = task th = _run(tid) th.join(timeout=30) assert _status(tid) == 'canceled', f"终态={_status(tid)}" assert task['result'] is None, '已终态不得被覆盖为 done' assert _balance() == before # ============ 结算兜底 ============ def test_settle_failure_does_not_flip_done_to_error(sandbox, monkeypatch): """交付已落定后结算抛错:任务仍为 done(钱由重启对账兜底,不坑已付费用户)。""" uid = _uid() def _boom(*a: Any, **k: Any) -> None: raise RuntimeError('库锁超时') _install_upstream(monkeypatch) monkeypatch.setattr(podcast_svc.auth_system, 'ledger_settle', _boom) tid, task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'done', f"终态={_status(tid)} msg={task.get('msg')}" assert task['result'] is not None def test_ledger_open_when_worker_crashes_before_settle(sandbox, monkeypatch): """worker 在结算前崩掉:账本行仍为 open(留给重启对账兜底,不是静默丢失)。 注意:光让 ledger_settle 抛错不够——成功路径对结算失败有 try/except 吞掉 (上一条测试验证的行为)。必须让下载本身先抛非预期异常走进通用 except, 连「退款结算」也没走成,账本才停留在 open。 """ uid = _uid() def _boom(*a: Any, **k: Any) -> None: raise RuntimeError('db busy') _install_upstream(monkeypatch, stream=_FakeStream(fail=RuntimeError('boom'))) monkeypatch.setattr(podcast_svc.auth_system, 'ledger_settle', _boom) tid, _task = _register_podcast(sandbox, user_id=uid) th = _run(tid) th.join(timeout=30) assert _status(tid) == 'error' row = asyncio.run(auth.ledger_get(tid)) assert row is not None assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}"