Archived
refactor: auth_system 全面异步化(aiosqlite),拆掉 web/services 层的 to_thread 桥接
拆掉两类垫片的最后一处:auth_system 原为同步 sqlite3 栈,web 层与 services 的
worker 只能靠 asyncio.to_thread 逐个桥接才不阻塞事件循环——每次 DB 访问都白付一次
线程池调度,且「同步调异步」的边界散落在 100+ 个调用点。B1 已把 worker 变成协程,
本批把访问层本身也迁到原生异步,两条线在此合拢。
改动(按子系统):
- auth_system:db.py 照 core/db.py 现有范式重建为原生异步访问层(aiosqlite + WAL)
——读路径 _q 每次独立连接、无锁、可并发;写路径 _x / write_transaction 用
asyncio.Lock 串行化,写者不阻塞读者。users / cards / credits / ledger / logs /
settings / mail / geo / backup 九模块随之全部 async/await;security.py 是纯计算
(pbkdf2/normalize)不动。__init__ 去掉 _DB_LOCK 与 get_conn 导出。
- web:app / deps / billing / tasks_registry 与 routers/{admin_site,auth,music,
register,user} 直接 await,拆掉包裹的 to_thread。deps.secret_key 改 async,
连带 _parse_cookie 也 async(会话解析要读密钥)。
- services:_taskcore / tasks / downloads / podcast 的账本调用与查询直接 await。
4 处 start_task 调用点(download.py)确认已 await。
设计取舍(为什么这么做,而不是直接包一层):
- 锁按**事件循环**隔离(_LOOP_LOCKS 以 id(loop) 为键):asyncio.Lock 绑定创建它的
loop,应用主 loop 与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different
event loop"。
- WAL 切换用**独立锁** _wal_lock 而非 _write_lock:execute() 已持写锁,再在锁内
connect→_configure 会自锁死(asyncio.Lock 非重入)。切换 + 标记在专用锁内串行并
二次复查,否则并发首连会同时执行 PRAGMA journal_mode=WAL 争排他锁报
database is locked(core/db.py 同款教训,实测复现)。
- force_wal 保留:journal_mode 持久化在库文件头,库文件被删重建时新文件不继承,
必须重设。exists() 判定须在 aiosqlite.connect **之前**(connect 会创建空文件)。
- connect() 用 DB_PATH.parent 兜底建目录,优于 DATA_DIR:生产两者相同,但测试
monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录。
留在 to_thread 上的仅剩**正当的阻塞卸载**,不是同步门面:PBKDF2 的 10 万次迭代
(登录链路每次都要跑)、smtplib 发信、备份文件的 copy2 / os.replace、secret.key
的文件 IO。原有 aria2 / downloader / zip 侧的 to_thread 未动。
不变量逐条保持(迁移只改执行模型,不改语义):
- ledger_settle 幂等:账本非 open 直接 return 0,重启后重放不重复退款;
- 终态先到先得:_mark_task_terminal 对已 done/error/canceled 的任务直接 return,
堵住 canceled→error 的非法迁移边;
- settle 必须在任务锁**外**调用(ledger_settle 要抢 SQLite 写锁,busy 最长 15s,
持任务锁会让全站进度/提交/取消随库停摆);
- reserve_and_open 的预扣与开账同事务,消除原两步之间的崩溃丢账窗口。
路径真源红线:DATA_DIR / DB_PATH / BACKUP_DIR / SECRET_KEY_PATH 仍是 auth_system.db
的**模块全局**(用与 core.db 相同的表达式,但**不是**它的别名)。测试只 patch
auth_system.db.*,若改成 core.db 的别名会静默把测试写到生产库。已核 data/site.db
读写前后 sha256 完全一致。
顺带清掉 get_conn:迁移后它只剩「转调 connect()」一层空壳、全仓零消费者(测试
直接开裸连接、备份用 db.connect()),保留等于留一条「拿裸连接自己写」的口子。
测试:19 个文件同步迁移——10 个文件的 web.deps.secret_key 替身改 async;
test_db_conn.py 的多线程首连用例按异步语义拆成 2 条(单 loop gather(16) 覆盖批量
并发首连、WAL 就绪后 8 线程各自 loop 建连覆盖跨 loop);test_billing_scope.py 去掉
多余的 @pytest.mark.anyio;被 await 且**正常返回**的 monkeypatch 替身一律改 async
(仅 raise 的同步替身仍可用)。
门禁:ruff 全过 / pyright 0-0-0(standard 模式含 tests 与顶层模块)/ pytest
384 项全绿(0 失败 0 error 0 skipped;383→384 因 test_db_conn 拆用例)。
data/site.db 读写前后 sha256 一致(a19f5db…6487),无 -wal/-shm 边车残留。
下一步 B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py 及
tests/test_async_bridge.py——三者在生产侧已零消费者(http_errors 仅 sync_http
自用,requests 已非声明依赖)。
This commit is contained in:
1 parent
69a50c2c31
commit
96af56cc27
43 files changed
+1171
-1009
No files matched your search
@@ -10,6 +10,7 @@
|
||||
本测试用真实 multipart 请求穿过端点,确保三种分支(接受 / 后缀拒 / 内容拒)行为正确。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
import pytest
|
||||
@@ -35,7 +36,7 @@ def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db')
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(admin_site.router)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""auth_system 同步栈的读写并发验证:读不持锁、写仍串行(WAL)。"""
|
||||
"""auth_system 异步栈的读写并发验证:读不持锁、写仍串行(WAL)。"""
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,33 +15,21 @@ def temp_site(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db')
|
||||
monkeypatch.setattr(auth_system.db, 'BACKUP_DIR', tmp_path / 'backups')
|
||||
monkeypatch.setattr(auth_system.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
auth_system.init_db()
|
||||
asyncio.run(auth_system.init_db())
|
||||
return tmp_path / 'site.db'
|
||||
|
||||
|
||||
def test_q_does_not_hold_lock(temp_site):
|
||||
"""_q 读不应获取 _DB_LOCK(否则读会被写挡住)。"""
|
||||
# 让另一个线程持有写锁,读仍应在短时间返回(不被阻塞)
|
||||
held = threading.Event()
|
||||
release = threading.Event()
|
||||
"""_q 读不应获取写锁(否则读会被写挡住)。"""
|
||||
|
||||
def holder():
|
||||
with auth_system._DB_LOCK:
|
||||
held.set()
|
||||
release.wait(5)
|
||||
async def _main():
|
||||
# 已在写锁内:读仍应立即返回(读路径不取写锁)
|
||||
async with auth_system.db._write_lock():
|
||||
rows = await auth_system._q('SELECT COUNT(*) AS n FROM users')
|
||||
assert rows is not None
|
||||
assert rows[0]['n'] >= 0
|
||||
|
||||
t = threading.Thread(target=holder, daemon=True)
|
||||
t.start()
|
||||
held.wait(2)
|
||||
try:
|
||||
start = time.monotonic()
|
||||
rows = auth_system._q('SELECT COUNT(*) AS n FROM users')
|
||||
elapsed = time.monotonic() - start
|
||||
assert rows is not None
|
||||
assert elapsed < 1.0, f'读被写锁阻塞了 {elapsed:.2f}s(WAL 下不应发生)'
|
||||
finally:
|
||||
release.set()
|
||||
t.join(2)
|
||||
asyncio.run(_main())
|
||||
|
||||
|
||||
def test_concurrent_reads_and_writes(temp_site):
|
||||
@@ -52,7 +40,7 @@ def test_concurrent_reads_and_writes(temp_site):
|
||||
def writer():
|
||||
try:
|
||||
for i in range(50):
|
||||
auth_system.set_setting(f'k{i}', str(i))
|
||||
asyncio.run(auth_system.set_setting(f'k{i}', str(i)))
|
||||
except Exception as e:
|
||||
errors.append(('w', e))
|
||||
finally:
|
||||
@@ -61,7 +49,7 @@ def test_concurrent_reads_and_writes(temp_site):
|
||||
def reader():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
auth_system.get_setting('k0')
|
||||
asyncio.run(auth_system.get_setting('k0'))
|
||||
except Exception as e:
|
||||
errors.append(('r', e))
|
||||
|
||||
@@ -75,7 +63,7 @@ def test_concurrent_reads_and_writes(temp_site):
|
||||
r.join(2)
|
||||
|
||||
assert not errors, f'并发读写异常: {errors[:3]}'
|
||||
assert auth_system.get_setting('k49') == '49'
|
||||
assert asyncio.run(auth_system.get_setting('k49')) == '49'
|
||||
|
||||
|
||||
def test_wal_enabled(temp_site):
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。
|
||||
"""
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
@@ -90,7 +91,7 @@ def _tables(dbx: dict[str, Any]) -> set[str]:
|
||||
|
||||
def test_fresh_db_creates_tables_and_admin(dbx, capsys):
|
||||
"""全新库:全部表就位、单行配置有种子行、初始管理员 role=admin 且余额 -1。"""
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
tables = _tables(dbx)
|
||||
for t in ('users', 'email_codes', 'cards', 'logs',
|
||||
@@ -115,7 +116,7 @@ def test_fresh_db_creates_tables_and_admin(dbx, capsys):
|
||||
|
||||
def test_second_run_is_idempotent(dbx):
|
||||
"""二次初始化:不报错、不重建管理员、已有数据原样保留。"""
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
conn = _conn(dbx)
|
||||
try:
|
||||
conn.execute("UPDATE users SET duration_seconds = 42 WHERE role = 'admin'")
|
||||
@@ -123,7 +124,7 @@ def test_second_run_is_idempotent(dbx):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
auth.init_db() # 不应抛
|
||||
asyncio.run(auth.init_db()) # 不应抛
|
||||
|
||||
conn = _conn(dbx)
|
||||
try:
|
||||
@@ -146,7 +147,7 @@ def test_legacy_db_columns_get_added(dbx):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
auth.init_db() # 不应抛
|
||||
asyncio.run(auth.init_db()) # 不应抛
|
||||
|
||||
assert {'status', 'login_seq', 'recent_provinces', 'ban_reason'} <= _cols(dbx, 'users')
|
||||
assert {'note', 'expire_at', 'disabled'} <= _cols(dbx, 'cards')
|
||||
@@ -166,7 +167,7 @@ def test_legacy_db_columns_get_added(dbx):
|
||||
|
||||
def test_renamed_admin_not_recreated(dbx):
|
||||
"""管理员改名(admin→qfbsh)后重启:按「无管理员角色」判断,不得再建新 admin。"""
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
conn = _conn(dbx)
|
||||
try:
|
||||
conn.execute("UPDATE users SET username = 'qfbsh' WHERE role = 'admin'")
|
||||
@@ -174,7 +175,7 @@ def test_renamed_admin_not_recreated(dbx):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
conn = _conn(dbx)
|
||||
try:
|
||||
@@ -200,7 +201,7 @@ def test_duplicate_nonempty_email_warns_and_continues(dbx, capsys):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
auth.init_db() # 不应抛(查重兜底在注册/建号路由,此处只告警)
|
||||
asyncio.run(auth.init_db()) # 不应抛(查重兜底在注册/建号路由,此处只告警)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert '重复的非空邮箱' in out, f'应打印人工清理提示,实际输出: {out!r}'
|
||||
@@ -225,7 +226,7 @@ def test_email_normalization_migrates_plus_tag(dbx):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
conn = _conn(dbx)
|
||||
try:
|
||||
@@ -246,7 +247,7 @@ def test_email_normalization_collision_keeps_original(dbx, capsys):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
auth.init_db() # 不应抛
|
||||
asyncio.run(auth.init_db()) # 不应抛
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert '重复' in out, f'应打印撞行告警,实际输出: {out!r}'
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
退出登录递增 login_seq、heartbeat 次数校验。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -16,6 +19,17 @@ USER_PW = 'UserPass123'
|
||||
ADMIN_PW = 'AdminPass123'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""同步读沙箱库(auth.db.DB_PATH 已被 fixture 指向临时库)。"""
|
||||
conn = sqlite3.connect(str(auth.db.DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app_env(tmp_path, monkeypatch):
|
||||
"""沙箱站点库 + 独立密钥,隔离生产数据。"""
|
||||
@@ -26,9 +40,9 @@ def app_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute('INSERT INTO users (username, password_hash, salt, role, duration_seconds, '
|
||||
'status, login_seq, created_at) VALUES (?,?,?,?,?,1,0,?)',
|
||||
('normaluser', auth._hash_new(USER_PW), '', 'user', 97, 1730000000))
|
||||
@@ -39,7 +53,7 @@ def app_env(tmp_path, monkeypatch):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
|
||||
# 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰
|
||||
import web.auth_state as auth_routes
|
||||
@@ -83,13 +97,13 @@ def test_admin_login_returns_2fa_ticket(app_env):
|
||||
|
||||
def test_logout_bumps_login_seq_and_clears(app_env):
|
||||
app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW})
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
seq_before = conn.execute(
|
||||
"SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0]
|
||||
conn.close()
|
||||
r = app_env.post('/auth/logout', json={})
|
||||
assert r.status_code == 200
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
seq_after = conn.execute(
|
||||
"SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0]
|
||||
conn.close()
|
||||
@@ -100,7 +114,7 @@ def test_logout_bumps_login_seq_and_clears(app_env):
|
||||
|
||||
|
||||
def test_heartbeat_expired_credits(app_env):
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute("UPDATE users SET duration_seconds=0 WHERE username='normaluser'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
+24
-31
@@ -5,6 +5,8 @@
|
||||
settle/rename_to 消费过的行天然不在退款范围,因此自动退与成功路径
|
||||
互不干扰,调用方无需手动取消。测试直接对真实账本(沙箱库)断言。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import auth_system as auth
|
||||
@@ -18,22 +20,22 @@ def ledger_env(tmp_path, monkeypatch):
|
||||
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)
|
||||
asyncio.run(auth.init_db())
|
||||
row = asyncio.run(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):
|
||||
async def _row(key: str):
|
||||
"""取账本行,不存在则测试失败(供「行必须存在」的断言用)。"""
|
||||
row = auth.ledger_get(key)
|
||||
row = await auth.ledger_get(key)
|
||||
assert row is not None, f'账本行 {key} 应存在'
|
||||
return row
|
||||
|
||||
|
||||
def _user_credits() -> int:
|
||||
u = auth.get_user_by_name('payer')
|
||||
async def _user_credits() -> int:
|
||||
u = await auth.get_user_by_name('payer')
|
||||
assert u is not None, '用户 payer 应存在'
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
@@ -43,81 +45,72 @@ def _make_billing(ledger_env, key: str | None) -> Billing:
|
||||
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)
|
||||
reserved = await auth.reserve_and_open(ledger_env['uid'], 1, key)
|
||||
assert reserved is not None
|
||||
assert _row(key)['status'] == 'open'
|
||||
assert (await _row(key))['status'] == 'open'
|
||||
|
||||
billing = _make_billing(ledger_env, key)
|
||||
async with billing.scope():
|
||||
pass # 业务体正常走完但未结算(等价于路由里的错误早退)
|
||||
|
||||
assert _row(key)['status'] == 'rejected'
|
||||
assert _user_credits() == 5
|
||||
assert (await _row(key))['status'] == 'rejected'
|
||||
assert await _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)
|
||||
await 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,未被二次退
|
||||
assert (await _row(key))['status'] == 'delivered'
|
||||
assert await _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)
|
||||
await 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 await auth.ledger_get(key) is None
|
||||
moved = await _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)
|
||||
await 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'
|
||||
assert (await _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() == []
|
||||
assert await _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()
|
||||
async def _open_rows():
|
||||
rows = await auth._q("SELECT task_id FROM billing_ledger WHERE status='open'")
|
||||
return [dict(r) for r in rows]
|
||||
+96
-65
@@ -1,74 +1,83 @@
|
||||
"""get_conn 连接策略契约:WAL 首连设置一次 + 目录自动创建。
|
||||
"""db.connect 连接策略契约:WAL 首连设置一次 + 目录自动创建。
|
||||
|
||||
背景:get_conn 原实现每个连接都执行 mkdir + PRAGMA journal_mode=WAL,
|
||||
实测每次多付 ~1.2ms(mkdir 190us + 新连接上 PRAGMA ~1ms)。WAL 模式
|
||||
持久化在库文件头里,与 core/db.py 的 _WAL_READY_FOR 同款,按路径设一次即可。
|
||||
背景:原同步实现每个连接都执行 mkdir + PRAGMA journal_mode=WAL,实测每次
|
||||
多付 ~1.2ms(mkdir 190us + 新连接上 PRAGMA ~1ms)。WAL 模式持久化在库文件头
|
||||
里,与 core/db.py 的 _WAL_READY_FOR 同款,按路径设一次即可。迁移到 aiosqlite
|
||||
后策略不变:`_configure` 在专用锁内首连设置一次,库文件被删重建时(force_wal)
|
||||
再补设。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import asyncio
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from auth_system import db
|
||||
|
||||
|
||||
def _point_db_to(monkeypatch, tmp_path: Path, conn_counter: list[int] | None = None):
|
||||
def _point_db_to(monkeypatch, tmp_path: Path, statements: list[str] | None = None):
|
||||
"""把 DB_PATH 指到临时库,并清空 WAL 已就绪缓存。"""
|
||||
new_path = tmp_path / 'site.db'
|
||||
monkeypatch.setattr(db, 'DB_PATH', new_path)
|
||||
monkeypatch.setattr(db, '_WAL_READY', set())
|
||||
if conn_counter is not None:
|
||||
real_connect = sqlite3.connect
|
||||
monkeypatch.setattr(db, '_WAL_READY_FOR', None)
|
||||
if statements is not None:
|
||||
real_connect = aiosqlite.connect
|
||||
|
||||
def counting_connect(*a, **k):
|
||||
conn_counter[0] += 1
|
||||
return real_connect(*a, **k)
|
||||
async def tracing_connect(*a, **k):
|
||||
# 在第一句 PRAGMA 之前挂追踪器,才能捕获 _configure 执行的语句
|
||||
conn = await real_connect(*a, **k)
|
||||
await conn.set_trace_callback(statements.append)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(db.sqlite3, 'connect', counting_connect)
|
||||
monkeypatch.setattr(db.aiosqlite, 'connect', tracing_connect)
|
||||
return new_path
|
||||
|
||||
|
||||
async def _open_close() -> None:
|
||||
conn = await db.connect()
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _journal_mode() -> str:
|
||||
conn = await db.connect()
|
||||
try:
|
||||
row = await (await conn.execute('PRAGMA journal_mode')).fetchone()
|
||||
assert row is not None
|
||||
return str(row[0]).lower()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def test_wal_mode_active_on_fresh_db(monkeypatch, tmp_path):
|
||||
"""新路径首连:WAL 生效。"""
|
||||
_point_db_to(monkeypatch, tmp_path)
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
mode = conn.execute('PRAGMA journal_mode').fetchone()[0]
|
||||
assert mode.lower() == 'wal'
|
||||
finally:
|
||||
conn.close()
|
||||
assert asyncio.run(_journal_mode()) == 'wal'
|
||||
|
||||
|
||||
def test_wal_pragma_only_once_per_path(monkeypatch, tmp_path):
|
||||
"""同一路径第二次起不再执行 PRAGMA journal_mode=WAL(用 SQL 追踪器实证)。"""
|
||||
_point_db_to(monkeypatch, tmp_path)
|
||||
|
||||
conn1 = db.get_conn()
|
||||
conn1.close()
|
||||
conn2 = db.get_conn()
|
||||
conn2.close()
|
||||
|
||||
# 用 sqlite3 的 set_trace_callback 记录第三条连接上的语句(2 连接已建立缓存)
|
||||
"""同一路径第二次起不再执行 PRAGMA journal_mode=WAL(SQL 追踪器实证)。"""
|
||||
statements: list[str] = []
|
||||
conn3 = db.get_conn()
|
||||
conn3.set_trace_callback(statements.append)
|
||||
conn3.execute('SELECT 1').fetchall()
|
||||
conn3.close()
|
||||
assert statements == ['SELECT 1'], f'第三条连接不应再跑 PRAGMA,实际: {statements}'
|
||||
_point_db_to(monkeypatch, tmp_path, statements=statements)
|
||||
|
||||
asyncio.run(_open_close())
|
||||
assert any('journal_mode=WAL' in s for s in statements), f'首连必须设 WAL,实际: {statements}'
|
||||
|
||||
statements.clear()
|
||||
asyncio.run(_open_close())
|
||||
assert not any('journal_mode=WAL' in s for s in statements), \
|
||||
f'第二条连接不应再设 WAL,实际: {statements}'
|
||||
# 常规 PRAGMA(busy_timeout 等)每连接仍照设
|
||||
assert any('busy_timeout' in s for s in statements), statements
|
||||
|
||||
|
||||
def test_new_path_resets_wal_cache(monkeypatch, tmp_path):
|
||||
"""换库路径(测试隔离场景):新路径重新设置 WAL。"""
|
||||
_point_db_to(monkeypatch, tmp_path / 'a')
|
||||
c = db.get_conn()
|
||||
c.close()
|
||||
asyncio.run(_open_close())
|
||||
|
||||
monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'b' / 'site.db')
|
||||
c = db.get_conn()
|
||||
try:
|
||||
mode = c.execute('PRAGMA journal_mode').fetchone()[0]
|
||||
assert mode.lower() == 'wal'
|
||||
finally:
|
||||
c.close()
|
||||
assert asyncio.run(_journal_mode()) == 'wal'
|
||||
|
||||
|
||||
def test_missing_dir_auto_created(monkeypatch, tmp_path):
|
||||
@@ -77,48 +86,70 @@ def test_missing_dir_auto_created(monkeypatch, tmp_path):
|
||||
_point_db_to(monkeypatch, deep)
|
||||
assert not deep.exists()
|
||||
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
conn.execute('CREATE TABLE t (v)').close()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
async def _main():
|
||||
conn = await db.connect()
|
||||
try:
|
||||
await conn.execute('CREATE TABLE t (v)')
|
||||
await conn.commit()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(_main())
|
||||
assert (deep / 'site.db').exists()
|
||||
|
||||
|
||||
def test_deleted_db_file_recovers(monkeypatch, tmp_path):
|
||||
"""库文件被删(目录还在):重连自动重建且恢复 WAL 模式,不崩。"""
|
||||
_point_db_to(monkeypatch, tmp_path)
|
||||
c = db.get_conn()
|
||||
c.close()
|
||||
asyncio.run(_open_close())
|
||||
|
||||
(tmp_path / 'site.db').unlink()
|
||||
conn = db.get_conn()
|
||||
try:
|
||||
conn.execute('SELECT 1').fetchall()
|
||||
mode = conn.execute('PRAGMA journal_mode').fetchone()[0]
|
||||
assert mode.lower() == 'wal', '重建的库文件必须恢复 WAL(journal_mode 不持久于新文件)'
|
||||
finally:
|
||||
conn.close()
|
||||
assert asyncio.run(_journal_mode()) == 'wal', '重建的库文件必须恢复 WAL(journal_mode 不持久于新文件)'
|
||||
|
||||
|
||||
def test_concurrent_first_connect_no_lease_conflict(monkeypatch, tmp_path):
|
||||
"""多线程同时首连(读路径不持 _DB_LOCK):WAL 设置正确、无连接报错。
|
||||
|
||||
core/db.py 记录过并发 PRAGMA 争排他锁报 database is locked 的实测坑,
|
||||
同步层用专用锁 + 二次复查同款防护。
|
||||
"""**单事件循环内**并发首连(生产模型:一个 loop 上并发多个请求):
|
||||
专用锁 + 二次复查保证 WAL 只切一次,无连接报错。
|
||||
"""
|
||||
import threading
|
||||
|
||||
_point_db_to(monkeypatch, tmp_path)
|
||||
|
||||
async def _main():
|
||||
async def _one():
|
||||
conn = await db.connect()
|
||||
try:
|
||||
await (await conn.execute('SELECT 1')).fetchall()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await asyncio.gather(*[_one() for _ in range(16)])
|
||||
|
||||
asyncio.run(_main())
|
||||
|
||||
|
||||
def test_concurrent_connect_from_threads_after_wal_ready(monkeypatch, tmp_path):
|
||||
"""WAL 已就绪后,多线程各自独立 loop 建连不再触排他锁争用(读路径并发)。
|
||||
|
||||
为何不再钉「多线程**同时首连**」:异步层的 WAL 切换锁按事件循环隔离
|
||||
(见 auth_system/db.py 的 _loop_lock,与 core/db.py 同款),跨 loop 的首连
|
||||
无法用同一把 asyncio.Lock 串行化——那是同步栈 threading.Lock 才有的保证。
|
||||
生产只有一个事件循环(任务监控线程的 asyncio.run 只在 init_db 之后启动),
|
||||
首连争用实际发生在单 loop 内,由上一条用例覆盖。
|
||||
"""
|
||||
_point_db_to(monkeypatch, tmp_path)
|
||||
asyncio.run(_open_close()) # 先把库切到 WAL,置 _WAL_READY_FOR
|
||||
errors: list[Exception] = []
|
||||
|
||||
def worker():
|
||||
async def _one():
|
||||
conn = await db.connect()
|
||||
try:
|
||||
await (await conn.execute('SELECT 1')).fetchall()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
try:
|
||||
for _ in range(5):
|
||||
c = db.get_conn()
|
||||
c.execute('SELECT 1').fetchall()
|
||||
c.close()
|
||||
asyncio.run(_one())
|
||||
except Exception as e: # 收集全部失败供断言
|
||||
errors.append(e)
|
||||
|
||||
|
||||
+6
-1
@@ -13,6 +13,11 @@ from web.errors import AppError
|
||||
SECRET = 'deps-test-secret'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
"""web.deps.secret_key 已异步化:替身须为协程函数(否则 await 处 TypeError)。"""
|
||||
return SECRET
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def site(tmp_path, monkeypatch):
|
||||
path = tmp_path / 'site.db'
|
||||
@@ -31,7 +36,7 @@ def site(tmp_path, monkeypatch):
|
||||
conn.close()
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', path)
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- 批量超 500:400 且退款
|
||||
- 参数校验失败各分支均按「只退 open 行」退费
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -18,6 +20,10 @@ SECRET = 'download-test-secret'
|
||||
PW = 'Passw0rd12'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'site.db'
|
||||
@@ -27,15 +33,15 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
# 单一密钥源:web 层统一经 web.deps.secret_key 取密钥(调用期解析),
|
||||
# patch 它即让登录签发与依赖校验同源
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
|
||||
# 造 3 次余额用户 + 0 次余额用户
|
||||
auth.create_user('payer', PW, email='p@example.com', credits=3)
|
||||
auth.create_user('broke', PW, email='b@example.com', credits=0)
|
||||
auth.create_user('rich', PW, email='r@example.com', credits=600)
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=3))
|
||||
asyncio.run(auth.create_user('broke', PW, email='b@example.com', credits=0))
|
||||
asyncio.run(auth.create_user('rich', PW, email='r@example.com', credits=600))
|
||||
|
||||
from web.app import create_app
|
||||
return TestClient(create_app())
|
||||
@@ -61,15 +67,14 @@ def _login(env, username):
|
||||
|
||||
|
||||
def _balance(username):
|
||||
u = auth.get_user_by_name(username)
|
||||
u = asyncio.run(auth.get_user_by_name(username))
|
||||
assert u is not None, f'用户 {username} 不存在,余额无从校验'
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
|
||||
def _open_ledger_rows():
|
||||
conn = auth.get_conn()
|
||||
rows = conn.execute("SELECT task_id, reserved, status FROM billing_ledger WHERE status='open'").fetchall()
|
||||
conn.close()
|
||||
rows = asyncio.run(auth._q(
|
||||
"SELECT task_id, reserved, status FROM billing_ledger WHERE status='open'"))
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ test_download_billing.py 覆盖参数校验与退费口径;本文件钉住 for
|
||||
(路由内延迟导入取的就是这个实例属性),MusicInfo/DownloadResult 用
|
||||
真实 dataclass 构造。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -22,6 +24,10 @@ SECRET = 'download-sync-test-secret'
|
||||
PW = 'Passw0rd12'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
def _info() -> MusicInfo:
|
||||
return MusicInfo(id=347230, name='晴天', artists='周杰伦', album='叶惠美',
|
||||
pic_url='', duration=269, track_number=1,
|
||||
@@ -44,9 +50,9 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
auth.create_user('payer', PW, email='p@example.com', credits=3)
|
||||
asyncio.run(auth.init_db())
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=3))
|
||||
|
||||
state: dict = {'info_exc': None, 'dl_result': _dl(), 'dl_exc': None}
|
||||
|
||||
@@ -81,16 +87,14 @@ def env(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _balance(username: str = 'payer') -> int:
|
||||
u = auth.get_user_by_name(username)
|
||||
u = asyncio.run(auth.get_user_by_name(username))
|
||||
assert u is not None, f'用户 {username} 不存在'
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
|
||||
def _open_rows() -> list[dict]:
|
||||
conn = auth.get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT task_id, status FROM billing_ledger WHERE status='open'").fetchall()
|
||||
conn.close()
|
||||
rows = asyncio.run(auth._q(
|
||||
"SELECT task_id, status FROM billing_ledger WHERE status='open'"))
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ _GOLDEN = Path(__file__).parent / 'golden' / 'responses.json'
|
||||
SECRET = 'golden-secret'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
"""web.deps.secret_key / auth_system.get_secret_key 均已异步化:
|
||||
替身须为协程函数,否则 await / asyncio.run 处会报 TypeError。"""
|
||||
return SECRET
|
||||
|
||||
|
||||
def _load_cases():
|
||||
return json.loads(_GOLDEN.read_text(encoding='utf-8'))
|
||||
|
||||
@@ -80,8 +86,8 @@ def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db')
|
||||
monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(core_db, 'DB_PATH', tmp_path / 'site.db')
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr(auth_system, 'get_secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
monkeypatch.setattr(auth_system, 'get_secret_key', _fake_secret_key)
|
||||
from web.app import create_app
|
||||
return TestClient(create_app(api_prefix=''))
|
||||
|
||||
|
||||
+24
-11
@@ -4,6 +4,9 @@
|
||||
test_auth_routes.py 覆盖成功主路径;本文件钉住 login 的各拒绝分支,
|
||||
作为 login 拆解的等价性验证器。
|
||||
"""
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -15,6 +18,16 @@ USER_PW = 'UserPass123'
|
||||
ADMIN_PW = 'AdminPass123'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(auth.db.DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'site.db'
|
||||
@@ -24,9 +37,9 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute('INSERT INTO users (username, password_hash, salt, role, duration_seconds, '
|
||||
'status, login_seq, created_at, email) VALUES (?,?,?,?,?,1,0,?,?)',
|
||||
('mailuser', auth._hash_new(USER_PW), '', 'user', 97, 1730000000,
|
||||
@@ -37,7 +50,7 @@ def env(tmp_path, monkeypatch):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
|
||||
import web.auth_state as auth_state
|
||||
auth_state._LOGIN_FAILS.clear()
|
||||
@@ -55,7 +68,7 @@ def _logs(conn, action: str) -> list[tuple[str, str]]:
|
||||
|
||||
def test_overlong_password_rejected_without_pbkdf2(env, monkeypatch):
|
||||
"""超长密码(>128):不碰 PBKDF2,直接按密码错误拒绝。"""
|
||||
def _boom(*a, **k):
|
||||
async def _boom(*a, **k):
|
||||
raise AssertionError('verify_login must not be called')
|
||||
|
||||
monkeypatch.setattr(auth, 'verify_login', _boom)
|
||||
@@ -90,7 +103,7 @@ def test_locked_log_written_once(env):
|
||||
env.post('/auth/login', json={'username': 'mailuser', 'password': 'nope'})
|
||||
for _ in range(3):
|
||||
env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW})
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
locked_logs = [d for t, d in _logs(conn, '登录') if d == '失败次数过多,临时锁定']
|
||||
conn.close()
|
||||
assert len(locked_logs) == 1
|
||||
@@ -98,21 +111,21 @@ def test_locked_log_written_once(env):
|
||||
|
||||
def test_banned_account_message(env, monkeypatch):
|
||||
"""封禁账号(status=2):文案带原因 + 失败日志。"""
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute("UPDATE users SET status=2, ban_reason='刷单' WHERE username='mailuser'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
r = env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW})
|
||||
assert r.json()['message'] == '账号已被封禁:刷单,请联系管理员'
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
assert ('fail', '封禁账号尝试登录: 刷单') in _logs(conn, '登录')
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_disabled_account_message(env):
|
||||
"""禁用账号(status=0):固定文案,无原因拼接。"""
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute("UPDATE users SET status=0 WHERE username='mailuser'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -126,7 +139,7 @@ def test_admin_without_email_blocked_fail_closed(env):
|
||||
r = env.post('/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW})
|
||||
assert r.json()['message'] == '管理员账号未绑定邮箱,请联系管理员先完成绑定'
|
||||
assert 'session' not in r.cookies
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
assert ('fail', '未绑定邮箱,两步验证不可用') in _logs(conn, '管理员登录')
|
||||
conn.close()
|
||||
|
||||
@@ -140,7 +153,7 @@ def test_geo_ban_bumps_seq_again(env, monkeypatch):
|
||||
r = env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW})
|
||||
assert r.json()['message'] == '检测到账号在多个省份异常登录,已自动封禁,请联系管理员'
|
||||
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
seq = conn.execute("SELECT login_seq FROM users WHERE username='mailuser'").fetchone()[0]
|
||||
conn.close()
|
||||
assert seq == 2 # 会话递增 + 封禁再递增
|
||||
@@ -148,7 +161,7 @@ def test_geo_ban_bumps_seq_again(env, monkeypatch):
|
||||
|
||||
def test_unexpected_error_is_500(env, monkeypatch):
|
||||
"""非预期异常:泛化文案 + HTTP 500。"""
|
||||
def _boom(*a, **k):
|
||||
async def _boom(*a, **k):
|
||||
raise RuntimeError('db busy')
|
||||
|
||||
monkeypatch.setattr(auth, 'verify_login', _boom)
|
||||
|
||||
+24
-13
@@ -13,6 +13,7 @@
|
||||
|
||||
账本口径同 tests/test_zip_workers.py:一律走 ``reserve_and_open`` 真预扣。
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -71,8 +72,8 @@ def sandbox(tmp_path, monkeypatch):
|
||||
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)
|
||||
asyncio.run(auth.init_db())
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS))
|
||||
|
||||
downloads = tmp_path / 'downloads'
|
||||
downloads.mkdir()
|
||||
@@ -207,8 +208,10 @@ def _live_worker() -> _WorkerHandle:
|
||||
def test_cancel_overtime_settles_dead_worker(sandbox, monkeypatch):
|
||||
"""超硬上限且 worker 已结束:置取消、唤醒 cancel Event、代为退款。"""
|
||||
settled: list[tuple] = []
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
async def _settle(tid, ok, st):
|
||||
settled.append((tid, ok, st))
|
||||
|
||||
monkeypatch.setattr(auth, 'ledger_settle', _settle)
|
||||
|
||||
task = _add_task('t-slow', status='running',
|
||||
start=time.time() - _HARD_TIMEOUT - 60,
|
||||
@@ -224,8 +227,10 @@ def test_cancel_overtime_without_worker_task_does_not_settle(sandbox, monkeypatc
|
||||
"""无 worker_task 字段的任务不代结算——代结算的前提是「句柄曾存在且已结束」,
|
||||
没有句柄就无法判断 worker 是否已在别处结算过,贸然退款会双退。"""
|
||||
settled: list[tuple] = []
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
async def _settle(tid, ok, st):
|
||||
settled.append((tid, ok, st))
|
||||
|
||||
monkeypatch.setattr(auth, 'ledger_settle', _settle)
|
||||
|
||||
task = _add_task('t-noth', status='queued',
|
||||
start=time.time() - _HARD_TIMEOUT - 60)
|
||||
@@ -238,8 +243,10 @@ def test_cancel_overtime_without_worker_task_does_not_settle(sandbox, monkeypatc
|
||||
def test_cancel_overtime_skips_live_worker(sandbox, monkeypatch):
|
||||
"""worker 仍在跑:只置取消,不代结算(交 worker 自己收敛,避免双退)。"""
|
||||
settled: list[tuple] = []
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
async def _settle(tid, ok, st):
|
||||
settled.append((tid, ok, st))
|
||||
|
||||
monkeypatch.setattr(auth, 'ledger_settle', _settle)
|
||||
|
||||
task = _add_task('t-live', status='running',
|
||||
start=time.time() - _HARD_TIMEOUT - 60, worker_task=_live_worker())
|
||||
@@ -260,10 +267,14 @@ def test_cancel_overtime_ignores_recent(sandbox):
|
||||
def test_ledger_jobs_respect_intervals(sandbox, monkeypatch):
|
||||
"""时间戳门槛:到期执行一次并推进门槛,未到期不重复执行。"""
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(auth, 'ledger_recover_orphans',
|
||||
lambda max_age_seconds=0: calls.append(f'recover:{max_age_seconds}'))
|
||||
monkeypatch.setattr(auth, 'ledger_cleanup_settled',
|
||||
lambda days: calls.append(f'cleanup:{days}'))
|
||||
async def _recover(max_age_seconds=0):
|
||||
calls.append(f'recover:{max_age_seconds}')
|
||||
|
||||
async def _cleanup(days):
|
||||
calls.append(f'cleanup:{days}')
|
||||
|
||||
monkeypatch.setattr(auth, 'ledger_recover_orphans', _recover)
|
||||
monkeypatch.setattr(auth, 'ledger_cleanup_settled', _cleanup)
|
||||
|
||||
now = time.time()
|
||||
services_tasks._run_periodic_ledger_jobs(now)
|
||||
@@ -281,7 +292,7 @@ def test_ledger_jobs_respect_intervals(sandbox, monkeypatch):
|
||||
|
||||
def test_ledger_jobs_swallow_exception(sandbox, monkeypatch):
|
||||
"""账本作业抛错由自身吞掉(不能让本轮其余作业一起落空)。"""
|
||||
def boom(max_age_seconds=0):
|
||||
async def boom(max_age_seconds=0):
|
||||
raise RuntimeError('库忙')
|
||||
|
||||
monkeypatch.setattr(auth, 'ledger_recover_orphans', boom)
|
||||
|
||||
@@ -111,8 +111,8 @@ def sandbox(tmp_path, monkeypatch):
|
||||
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)
|
||||
asyncio.run(auth.init_db())
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS))
|
||||
|
||||
downloads = tmp_path / 'downloads'
|
||||
downloads.mkdir()
|
||||
@@ -131,13 +131,13 @@ def sandbox(tmp_path, monkeypatch):
|
||||
# ============ 工具 ============
|
||||
|
||||
def _uid(username: str = 'payer') -> int:
|
||||
u = auth.get_user_by_name(username)
|
||||
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 = auth.get_user_by_name(username)
|
||||
u = asyncio.run(auth.get_user_by_name(username))
|
||||
assert u is not None
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
@@ -150,7 +150,7 @@ def _status(task_id: str) -> str:
|
||||
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)
|
||||
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,
|
||||
@@ -238,7 +238,7 @@ def test_podcast_download_completes(sandbox, monkeypatch):
|
||||
assert delivered.parent == sandbox['downloads'] / 'tasks' / tid
|
||||
assert delivered.read_bytes() == b'audio-bytes'
|
||||
|
||||
row = auth.ledger_get(tid)
|
||||
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 次,无退款'
|
||||
@@ -372,7 +372,7 @@ def test_missing_song_id_marks_error_and_refunds(sandbox, monkeypatch):
|
||||
assert task['msg'] == '该单集缺少音频信息(mainSong)'
|
||||
assert task['result'] is None
|
||||
assert _balance() == before, '失败应全额退回预扣'
|
||||
row = auth.ledger_get(tid)
|
||||
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(), '失败应清掉任务目录'
|
||||
|
||||
@@ -556,6 +556,6 @@ def test_ledger_open_when_worker_crashes_before_settle(sandbox, monkeypatch):
|
||||
th.join(timeout=30)
|
||||
|
||||
assert _status(tid) == 'error'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None
|
||||
assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}"
|
||||
+20
-7
@@ -8,6 +8,8 @@
|
||||
- 找回密码:重置后可用新密码登录、旧会话失效
|
||||
- 卡密充值
|
||||
"""
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -19,6 +21,17 @@ import core.db as db
|
||||
SECRET = 'register-test-secret'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""直连沙箱库做只读/播种(auth.get_conn 已异步化,测试用裸连接更直接)。"""
|
||||
conn = sqlite3.connect(str(auth.db.DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'site.db'
|
||||
@@ -28,8 +41,8 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
asyncio.run(auth.init_db())
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
import web.auth_state as auth_routes
|
||||
auth_routes._SEND_CODE_IP.clear()
|
||||
from web.app import create_app
|
||||
@@ -37,7 +50,7 @@ def env(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _seed_code(email: str, code: str, purpose: str = 'register'):
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
|
||||
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) '
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
@@ -84,7 +97,7 @@ def test_register_bad_code(env):
|
||||
|
||||
def test_forgot_password_resets(env):
|
||||
# 建一个用户
|
||||
auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5)
|
||||
asyncio.run(auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5))
|
||||
_seed_code('f@example.com', '654321', purpose='reset')
|
||||
r = env.post('/auth/forgot_password', json={
|
||||
'email': 'f@example.com', 'code': '654321', 'password': 'NewPass456'})
|
||||
@@ -96,7 +109,7 @@ def test_forgot_password_resets(env):
|
||||
|
||||
|
||||
def test_forgot_password_admin_blocked(env):
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute("UPDATE users SET role='admin', email='boss@example.com' WHERE id=1")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -109,8 +122,8 @@ def test_forgot_password_admin_blocked(env):
|
||||
|
||||
def test_redeem_card(env):
|
||||
# 造一张卡密并让用户登录
|
||||
auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0)
|
||||
code = auth.generate_cards(1, 10, note='test')[0]
|
||||
asyncio.run(auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0))
|
||||
code = asyncio.run(auth.generate_cards(1, 10, note='test'))[0]
|
||||
env.post('/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'})
|
||||
r = env.post('/auth/redeem', json={'code': code})
|
||||
assert r.status_code == 200
|
||||
|
||||
+28
-10
@@ -7,6 +7,7 @@ create_user 的三种异常映射,作为 register 拆解的等价性验证器
|
||||
验证码由 check_verify_code 以 DELETE 原子消费:失败路径后 email_codes 行
|
||||
仍然存在 ⇔ restore_verify_code 确实被调用。以此做实证断言,不 mock 还原。
|
||||
"""
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
@@ -19,6 +20,17 @@ import core.db as db
|
||||
SECRET = 'register-flow-test-secret'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return SECRET
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""直连沙箱库做只读/播种(auth.get_conn 已异步化,测试用裸连接更直接)。"""
|
||||
conn = sqlite3.connect(str(auth.db.DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / 'site.db'
|
||||
@@ -28,8 +40,8 @@ def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
||||
auth.init_db()
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
asyncio.run(auth.init_db())
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
import web.auth_state as auth_state
|
||||
auth_state._SEND_CODE_IP.clear()
|
||||
from web.app import create_app
|
||||
@@ -37,7 +49,7 @@ def env(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _seed_code(email: str, code: str, purpose: str = 'register'):
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
|
||||
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) '
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
@@ -47,7 +59,7 @@ def _seed_code(email: str, code: str, purpose: str = 'register'):
|
||||
|
||||
|
||||
def _code_rows(email: str, purpose: str = 'register') -> list[str]:
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
rows = conn.execute('SELECT code FROM email_codes WHERE email = ? AND purpose = ?',
|
||||
(email, purpose)).fetchall()
|
||||
conn.close()
|
||||
@@ -55,7 +67,7 @@ def _code_rows(email: str, purpose: str = 'register') -> list[str]:
|
||||
|
||||
|
||||
def _register_logs() -> list[tuple[str, str]]:
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
rows = conn.execute("SELECT type, detail FROM logs WHERE action = '注册'").fetchall()
|
||||
conn.close()
|
||||
return [(r['type'], r['detail']) for r in rows]
|
||||
@@ -86,7 +98,7 @@ def test_register_input_validation_order(env):
|
||||
|
||||
def test_dup_username_restores_code_end_to_end(env):
|
||||
"""用户名重名拒绝后,同一验证码无需重发即可再次使用(restore 实证)。"""
|
||||
auth.create_user('taken', 'Passw0rd12')
|
||||
asyncio.run(auth.create_user('taken', 'Passw0rd12'))
|
||||
_seed_code('x@example.com', '123456')
|
||||
|
||||
r = _post(env, 'taken', 'x@example.com', '123456')
|
||||
@@ -98,7 +110,7 @@ def test_dup_username_restores_code_end_to_end(env):
|
||||
|
||||
def test_dup_email_keeps_code_row(env):
|
||||
"""邮箱重名拒绝后验证码行仍在(被 check_verify_code 消费后又还原)。"""
|
||||
auth.create_user('holder', 'Passw0rd12', email='y@example.com')
|
||||
asyncio.run(auth.create_user('holder', 'Passw0rd12', email='y@example.com'))
|
||||
_seed_code('y@example.com', '111111')
|
||||
|
||||
r = _post(env, 'maildup', 'y@example.com', '111111')
|
||||
@@ -108,7 +120,10 @@ def test_dup_email_keeps_code_row(env):
|
||||
|
||||
def test_ip_exceeded_restores_code_and_logs(env, monkeypatch):
|
||||
"""IP 闸触发:还原验证码 + 写失败日志 + 专属文案。"""
|
||||
monkeypatch.setattr(auth, 'register_ip_exceeded', lambda ip, **k: True)
|
||||
async def _exceeded(ip, **k):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth, 'register_ip_exceeded', _exceeded)
|
||||
_seed_code('ip@example.com', '222222')
|
||||
|
||||
r = _post(env, 'ipuser', 'ip@example.com', '222222')
|
||||
@@ -130,7 +145,7 @@ def test_device_used_grants_zero_credits(env):
|
||||
assert r2.json()['success'] is True
|
||||
assert r2.json()['message'] == '注册成功'
|
||||
|
||||
conn = auth.get_conn()
|
||||
conn = _connect()
|
||||
row = conn.execute("SELECT duration_seconds FROM users WHERE username = 'seconddev'").fetchone()
|
||||
conn.close()
|
||||
assert row['duration_seconds'] == 0
|
||||
@@ -167,7 +182,10 @@ def test_valueerror_maps_and_restores(env, monkeypatch):
|
||||
|
||||
def test_user_none_maps_and_restores(env, monkeypatch):
|
||||
"""建号事务提交却读不回:按注册失败处理 + 还原验证码 + 失败日志。"""
|
||||
monkeypatch.setattr(auth, 'create_user', lambda *a, **k: None)
|
||||
async def _none(*a, **k):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(auth, 'create_user', _none)
|
||||
_seed_code('none@example.com', '777777')
|
||||
|
||||
r = _post(env, 'noneuser', 'none@example.com', '777777')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""纯 FastAPI 模式(api_prefix='')测试:前端全部路径由 FastAPI 服务。"""
|
||||
import asyncio
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
@@ -41,8 +42,12 @@ def root_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
||||
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db')
|
||||
auth.init_db()
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: 'root-mode-secret')
|
||||
asyncio.run(auth.init_db())
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
return 'root-mode-secret'
|
||||
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
|
||||
from web.app import create_app
|
||||
return TestClient(create_app(api_prefix=''))
|
||||
|
||||
@@ -101,8 +101,8 @@ def sandbox(tmp_path, monkeypatch):
|
||||
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)
|
||||
asyncio.run(auth.init_db())
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS))
|
||||
|
||||
downloads = tmp_path / 'downloads'
|
||||
downloads.mkdir()
|
||||
@@ -128,13 +128,13 @@ def sandbox(tmp_path, monkeypatch):
|
||||
# ============ 工具 ============
|
||||
|
||||
def _uid(username: str = 'payer') -> int:
|
||||
u = auth.get_user_by_name(username)
|
||||
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 = auth.get_user_by_name(username)
|
||||
u = asyncio.run(auth.get_user_by_name(username))
|
||||
assert u is not None
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
@@ -148,7 +148,7 @@ def _register_single(sandbox, *, music_id: int = 1, user_id: int,
|
||||
reserved: int = 1, **params: Any) -> tuple[str, dict[str, Any]]:
|
||||
tid = 's' + str(int(time.time() * 1000000) % 10 ** 10)
|
||||
# reserve_and_open:真预扣 + 开账同事务(ledger_open 只记账不扣款,见模块 docstring)
|
||||
left = auth.reserve_and_open(user_id, reserved, tid)
|
||||
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,
|
||||
@@ -215,7 +215,7 @@ def test_single_download_completes(sandbox, monkeypatch):
|
||||
assert delivered.read_bytes() == (sandbox['cache'] / '101.mp3').read_bytes()
|
||||
assert (sandbox['cache'] / '101.mp3').exists(), '共享缓存不应被搬走'
|
||||
|
||||
row = auth.ledger_get(tid)
|
||||
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 次,无退款'
|
||||
@@ -354,7 +354,7 @@ def test_download_failure_marks_error_and_refunds(sandbox, monkeypatch):
|
||||
assert task['msg'] == '上游 404'
|
||||
assert task['result'] is None
|
||||
assert _balance() == before, '失败应全额退回预扣'
|
||||
row = auth.ledger_get(tid)
|
||||
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(), '失败应清掉任务目录'
|
||||
|
||||
@@ -399,7 +399,7 @@ def test_cancel_mid_download(sandbox, monkeypatch):
|
||||
assert task['msg'] == '任务已取消'
|
||||
assert task['result'] is None
|
||||
assert _balance() == before, '取消应全额退回预扣'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 0
|
||||
assert not (sandbox['downloads'] / 'tasks' / tid).exists()
|
||||
|
||||
@@ -586,6 +586,6 @@ def test_ledger_open_when_worker_crashes_before_settle(sandbox, monkeypatch):
|
||||
th = _run(tid)
|
||||
th.join(timeout=30)
|
||||
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None
|
||||
assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}"
|
||||
@@ -33,6 +33,11 @@ SECRET = 'song-test-secret'
|
||||
PATH = '/song'
|
||||
|
||||
|
||||
async def _fake_secret_key() -> str:
|
||||
"""web.deps.secret_key 已异步化:替身必须是协程函数,同步 lambda 会在 await 处报错。"""
|
||||
return SECRET
|
||||
|
||||
|
||||
class FakeAPI:
|
||||
"""music_api.NeteaseAPI 的替身:记录调用,返回预置结果或抛预置异常。"""
|
||||
|
||||
@@ -121,7 +126,7 @@ def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(core_db, 'DB_PATH', path)
|
||||
monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path)
|
||||
monkeypatch.setattr(auth_system.db, 'DB_PATH', path)
|
||||
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
||||
monkeypatch.setattr('web.deps.secret_key', _fake_secret_key)
|
||||
from web.app import create_app
|
||||
app = TestClient(create_app(api_prefix=''))
|
||||
cookie = cs.dumps(SECRET, {'site_uid': 1, 'site_uid_seq': 7, 'site_uid_ts': int(time.time())})
|
||||
|
||||
+14
-14
@@ -98,8 +98,8 @@ def sandbox(tmp_path, monkeypatch):
|
||||
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)
|
||||
asyncio.run(auth.init_db())
|
||||
asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS))
|
||||
|
||||
downloads = tmp_path / 'downloads'
|
||||
downloads.mkdir()
|
||||
@@ -118,13 +118,13 @@ def sandbox(tmp_path, monkeypatch):
|
||||
# ============ 工具 ============
|
||||
|
||||
def _uid(username: str = 'payer') -> int:
|
||||
u = auth.get_user_by_name(username)
|
||||
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 = auth.get_user_by_name(username)
|
||||
u = asyncio.run(auth.get_user_by_name(username))
|
||||
assert u is not None
|
||||
return int(u['duration_seconds'] or 0)
|
||||
|
||||
@@ -156,7 +156,7 @@ def _register_batch(sandbox, ids: list[int], *, reserved: int, user_id: int,
|
||||
tid = 't' + str(int(time.time() * 1000000) % 10 ** 10)
|
||||
# 必须走 reserve_and_open(真预扣+开账同事务)——ledger_open 只记账不扣款,
|
||||
# 用它会让「退款」凭空给余额加次数,账本守恒断言失去意义
|
||||
left = auth.reserve_and_open(user_id, reserved, tid)
|
||||
left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid))
|
||||
assert left is not None, '预扣失败(余额不足,检查 fixture 的 credits)'
|
||||
task: dict[str, Any] = {
|
||||
'type': 'batch', 'task_id': tid,
|
||||
@@ -179,7 +179,7 @@ def _register_batch(sandbox, ids: list[int], *, reserved: int, user_id: int,
|
||||
|
||||
def _register_podcast(sandbox, pids: list[int], *, reserved: int, user_id: int) -> tuple[str, dict[str, Any]]:
|
||||
tid = 'p' + str(int(time.time() * 1000000) % 10 ** 10)
|
||||
left = auth.reserve_and_open(user_id, reserved, tid)
|
||||
left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid))
|
||||
assert left is not None, '预扣失败(余额不足)'
|
||||
task: dict[str, Any] = {
|
||||
'type': 'podcast_batch', 'task_id': tid,
|
||||
@@ -234,7 +234,7 @@ def test_batch_zip_completes(sandbox, monkeypatch):
|
||||
assert sum(1 for n in names if n.endswith('.mp3')) == 3, names
|
||||
assert sum(1 for n in names if n.endswith('.lrc')) == 3, names
|
||||
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['status'] != 'open'
|
||||
assert row['delivered'] == 3
|
||||
assert _balance() == before - 3, '全部成功:只扣预扣的 3 次,无退款'
|
||||
@@ -288,7 +288,7 @@ def test_batch_zip_pause_then_cancel(sandbox, monkeypatch):
|
||||
assert task['result'] is None, '未交付不应发布 ZIP'
|
||||
assert task['msg'] == '任务已取消(已退回 3 次下载次数)', task['msg']
|
||||
assert _balance() == before, '取消后应全额退回预扣'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 0 and row['status'] != 'open'
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ def test_batch_zip_all_fail_refunds(sandbox, monkeypatch):
|
||||
assert task['failed'] == 2
|
||||
assert task['result'] is None, '全失败不应发布 ZIP'
|
||||
assert _balance() == before, '全失败应全额退回预扣'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 0 and row['status'] != 'open'
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ def test_batch_zip_midway_cancel_refunds_all(sandbox, monkeypatch):
|
||||
assert _status(tid) == 'canceled', f"终态={_status(tid)} msg={task.get('msg')}"
|
||||
assert task['result'] is None, '当前实现:循环内取消不发布'
|
||||
assert _balance() == before, '未交付部分全额退回'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 0
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ def test_podcast_zip_completes(podcast_stub):
|
||||
assert task['done'] == 3 and task['failed'] == 0
|
||||
names = _zip_names(task['result']['zip_path'])
|
||||
assert sum(1 for n in names if n.endswith('.mp3')) == 3, names
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 3
|
||||
assert _balance() == before - 3
|
||||
|
||||
@@ -467,7 +467,7 @@ def test_podcast_zip_all_fail_refunds(podcast_stub, monkeypatch):
|
||||
|
||||
assert _status(tid) == 'error', f"终态={_status(tid)} msg={task.get('msg')}"
|
||||
assert _balance() == before, '全失败应全额退回'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['delivered'] == 0
|
||||
|
||||
|
||||
@@ -525,7 +525,7 @@ def test_batch_all_fail_message_and_ledger_status(sandbox, monkeypatch):
|
||||
th.join(timeout=30)
|
||||
|
||||
assert task['msg'] == '全部失败(2 首),已退回 2 次', task['msg']
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['status'] == 'error'
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ def test_podcast_all_fail_message_and_ledger_status(podcast_stub, monkeypatch):
|
||||
), task['msg']
|
||||
assert task['result'] is None, '不发布空包'
|
||||
assert _balance() == before, '全失败应全额退回'
|
||||
row = auth.ledger_get(tid)
|
||||
row = asyncio.run(auth.ledger_get(tid))
|
||||
assert row is not None and row['status'] == 'error' and row['delivered'] == 0
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user