Archived
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。 worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给 另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成 协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。 改动(按子系统): - music_downloader:downloader / _aria2 / _concurrency / cover / _http / __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改 「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让 已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经 to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式 读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已 有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。 删除 get_http_session / _SESSION_LOCAL。 models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception: 它是「续传起点失效、残片已清、从头重下」,既不是确定性失败 (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立 成类且在 except 链中先于二者捕获。 - music_api:podcast.py 全部函数协程化(直链解析内部改 await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已 清空,文件只剩空壳。 - services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。 threading.Thread → loop.create_task(任务句柄字段 worker_thread → worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore, 终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。 删除 services/streaming.py(同步流垫片,唯一消费者已迁到 core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、 「入包」严格串行。 - web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是 把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。 web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。 web/routers/download.py 的播客端点补上 3 处漏掉的 await (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象, 靠 pyright 抓出来后修正。 留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、 mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、 PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/ 往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject 逐条注明理由。 取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker 协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件 循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。 测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的 get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks (顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...)); aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成 done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在 常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。 门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。 下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾; B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅 sync_http 自用)。
562 lines
21 KiB
Python
562 lines
21 KiB
Python
# -*- 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)
|
||
auth.init_db()
|
||
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 = auth.get_user_by_name(username)
|
||
assert u is not None
|
||
return int(u['id'])
|
||
|
||
|
||
def _balance(username: str = 'payer') -> int:
|
||
u = 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 = 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 = 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 = 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 = auth.ledger_get(tid)
|
||
assert row is not None
|
||
assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}"
|