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_billing_scope.py
T
snowy-yang 6fb37534d3 refactor: 计费退款不变量收敛为 Billing.scope()(download.py 25 处手工退款 → 0)
为什么:「任何失败路径必须退款」此前靠路由里每条早退/except 手工调
refund_open 维持(download.py 单文件 25 处),上一轮拆 register/login
时已经体会过这个模式的代价——改任何一条返回路径都要核对还原时序,
漏一处不会有测试报警,只剩重启对账兜底在跑。

改动:
- Billing.scope()(asynccontextmanager):作用域退出时自动退还仍 open
  的账本行。正确性完全依赖 refund_open 的既有幂等口径(只退仍 open
  的行)——settle()/rename_to() 消费过的行天然豁免,成功路径零动作,
  无需手动「取消自动退」;未捕获异常先退款再原样穿透。
- download.py 四个端点全部改用 async with billing.scope(),删除全部
  手工退款与 _fail 收尾闭包;_sync_download_json 的内部退款一并移除
  (docstring 标明必须在 scope 内运行)。
- 语义逐路径等价:错误早退→自动退(同原手工);register 成功后
  rename→自动退 no-op(同原手工退的 no-op);start 失败行挂 task_id
  留给对账(同原);CancelledError 语义不变(退款随取消中断,对账兜底)。

先写测试后动代码:tests/test_billing_scope.py 5 例(TDD 红→绿),
对真实沙箱账本断言——open 行自动退、settle 后 no-op 且余额恰 -1、
rename 后请求级行消失且任务级行保持 open、异常穿透、free 路径零动作。
既有 30 例计费/下载端到端测试作迁移回归网。
门禁:ruff 全过 · pyright 0/0 · pytest 387 passed(382→387)。
2026-09-27 12:13:00 +08:00

124 lines
4.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 -*-
"""Billing.scope() 契约:作用域退出自动退还仍 open 的账本行。
设计前提(已由既有实现保证):refund_open 只退「仍 open」的行——
settle/rename_to 消费过的行天然不在退款范围,因此自动退与成功路径
互不干扰,调用方无需手动取消。测试直接对真实账本(沙箱库)断言。
"""
import pytest
import auth_system as auth
from web.billing import Billing
from web.deps import SiteUser
@pytest.fixture()
def ledger_env(tmp_path, monkeypatch):
monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(auth.db, 'DB_PATH', tmp_path / 'site.db')
monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups')
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
auth.init_db()
row = auth.create_user('payer', 'Passw0rd12', email='p@example.com', credits=5)
assert row is not None, '建号应成功'
yield {'uid': int(row['id'])}
# 无需清理:tmp_path 由 pytest 回收
def _row(key: str):
"""取账本行,不存在则测试失败(供「行必须存在」的断言用)。"""
row = auth.ledger_get(key)
assert row is not None, f'账本行 {key} 应存在'
return row
def _user_credits() -> int:
u = auth.get_user_by_name('payer')
assert u is not None, '用户 payer 应存在'
return int(u['duration_seconds'] or 0)
def _make_billing(ledger_env, key: str | None) -> Billing:
user = SiteUser(id=ledger_env['uid'], username='payer', role='user', credits=5)
return Billing(user=user, ledger_key=key, need=1, free=False)
@pytest.mark.anyio
async def test_scope_auto_refunds_open_row(ledger_env):
"""正常退出作用域:仍 open 的行被退(rejected),余额恢复。"""
key = 'req-aaa'
reserved = auth.reserve_and_open(ledger_env['uid'], 1, key)
assert reserved is not None
assert _row(key)['status'] == 'open'
billing = _make_billing(ledger_env, key)
async with billing.scope():
pass # 业务体正常走完但未结算(等价于路由里的错误早退)
assert _row(key)['status'] == 'rejected'
assert _user_credits() == 5
@pytest.mark.anyio
async def test_scope_noop_after_settle(ledger_env):
"""作用域内已结算:退出自动退为 no-op,交付行不受影响。"""
key = 'req-bbb'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
async with billing.scope():
await billing.settle(1, 'delivered')
assert _row(key)['status'] == 'delivered'
assert _user_credits() == 4 # 恰好扣 1,未被二次退
@pytest.mark.anyio
async def test_scope_noop_after_rename(ledger_env):
"""作用域内账本已改挂 task_id:请求级行不存在,自动退为 no-op;
任务级行保持 open 交给 worker 结算——与迁移前手工语义逐路径一致。"""
key = 'req-ccc'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
async with billing.scope():
ok = await billing.rename_to('task-xyz')
assert ok is True
assert auth.ledger_get(key) is None
moved = _row('task-xyz')
assert moved['status'] == 'open'
@pytest.mark.anyio
async def test_scope_refunds_on_exception_and_reraises(ledger_env):
"""业务体抛异常:先退款,异常原样穿透(路由自行转错误响应)。"""
key = 'req-ddd'
auth.reserve_and_open(ledger_env['uid'], 1, key)
billing = _make_billing(ledger_env, key)
with pytest.raises(ValueError, match='boom'):
async with billing.scope():
raise ValueError('boom')
assert _row(key)['status'] == 'rejected'
@pytest.mark.anyio
async def test_scope_free_billing_noop(ledger_env):
"""free/unlimited(ledger_key=None):作用域进出零动作。"""
billing = _make_billing(ledger_env, None)
async with billing.scope():
pass
# 无账本行可动,不抛异常即为通过
assert _open_rows() == []
def _open_rows():
conn = auth.get_conn()
try:
rows = conn.execute("SELECT task_id FROM billing_ledger WHERE status='open'").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()