Files
group_xinghuo_jinrong/tests/test_convert_repository.py
T
GaoYiYuan_0626andWorkBuddy 4fd2ec05c8 基金转换 T-4+T-5(并行组 A):convert_repository 代理侧 + locks.try_lock 非阻塞抢锁
T-4(convert_repository · agent 库 risk_convert_detail):
- 新增 7 方法:insert_placeholder(占位 pending) / complete_convert(回写 completed+折算详情)
  / mark_failed / get_by_group_id / get_by_client_request_id(None 直接返回 None)
  / list_expired_candidates(hours) / mark_expired(S2 标记不硬删)
- 引擎走 agent 库 rw(D20);status 5 值枚举;不与 risk_repository 混职责
- Decimal 折算字段经 _to_bind 转 float 绑定(sqlite 不支持直接绑 Decimal,同 T-3)

T-5(locks.try_lock):
- 单次非阻塞抢锁:抢到返回 _Token(with 进入 True),抢不到立即 _NoLock(进入 False,不等不降级)
- Redis 不可用退回进程内 Lock.acquire(blocking=False);_NoLock.__enter__ 返回 False 避免 with None 报错
- 不改 run_locked(D4:其等 2s 后降级 fn(False),语义相反;3 处调用点零改动)

测试:test_convert_repository.py 6 用例 + test_locks_redis.py +4 用例(既有 6 例零改动)
pytest 全量 634 passed / 3 skipped(基线 624 + 10,零回归)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-09-10 15:53:31 +08:00

142 lines
5.8 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.
"""T-4 `convert_repository`(agent 侧 risk_convert_detail)单测(开发计划 §5.2 DoD)。
sqlite 内存库(conftest.sqlite_engine 含 agent 库表 + risk_convert_detail);
数据由本文件经仓储自建,不依赖真 MySQL。
覆盖:
1. 占位 → completed(阶段二回写详情,含 Decimal 折算字段)
2. 占位 → failed
3. 超时 → expired(list_expired_candidates + mark_expired,S2 标记不硬删)
4. client_request_id 为 None 不受 uk_idem 约束(多次占位互不冲突)
5. get_by_client_request_id 幂等命中 / None 直接返回 None
6. 不与 risk_repository 混职责(convert 写操作不动 risk_alert)
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import text
from app.repository.convert_repository import ConvertRepository
from app.repository.risk_repository import RiskRepository
def _backdate_created_at(engine, group_id: str, hours_ago: int) -> None:
"""把占位行的 created_at 回拨,模拟超时(测试不依赖真实时间漂移)。"""
old = datetime.now() - timedelta(hours=hours_ago)
with engine.begin() as conn:
conn.execute(
text("UPDATE risk_convert_detail SET created_at = :ts WHERE convert_group_id = :gid"),
{"ts": old, "gid": group_id},
)
# ── 1. 占位 → completed ────────────────────────────────────────────────
def test_placeholder_then_complete(sqlite_engine):
repo = ConvertRepository(engine=sqlite_engine)
repo.insert_placeholder("G-1", "REQ-1")
pending = repo.get_by_group_id("G-1")
assert pending is not None
assert pending["status"] == "pending"
assert pending["estimated"] == 0 # 真实请求,非预估单
repo.complete_convert(
"G-1",
out_trade_id="TRD-OUT-1",
in_trade_id="TRD-IN-1",
related_trade_id="TRD-REL-1",
nav=Decimal("1.0234"),
nav_date=date(2026, 9, 4),
fee_amount=Decimal("252.40"),
hold_days_min=3,
hold_days_max=180,
nav_stale=True,
)
done = repo.get_by_group_id("G-1")
assert done["status"] == "completed"
assert done["out_trade_id"] == "TRD-OUT-1"
assert done["in_trade_id"] == "TRD-IN-1"
assert done["related_trade_id"] == "TRD-REL-1"
assert done["nav"] == pytest.approx(1.0234) # Decimal→float 绑定,读回一致
assert done["fee_amount"] == pytest.approx(252.40)
assert date.fromisoformat(str(done["nav_date"])) == date(2026, 9, 4)
assert done["hold_days_min"] == 3
assert done["hold_days_max"] == 180
assert done["nav_stale"] == 1
# ── 2. 占位 → failed ───────────────────────────────────────────────────
def test_placeholder_then_failed(sqlite_engine):
repo = ConvertRepository(engine=sqlite_engine)
repo.insert_placeholder("G-2", "REQ-2")
repo.mark_failed("G-2")
assert repo.get_by_group_id("G-2")["status"] == "failed"
# ── 3. 超时 → expired(S2 标记不硬删) ───────────────────────────────────
def test_expired_lifecycle(sqlite_engine):
repo = ConvertRepository(engine=sqlite_engine)
repo.insert_placeholder("G-3", "REQ-3")
_backdate_created_at(sqlite_engine, "G-3", hours_ago=48)
# 超 24h 的 pending 应被捞出
candidates = repo.list_expired_candidates(24)
assert any(c["convert_group_id"] == "G-3" for c in candidates)
repo.mark_expired("G-3")
row = repo.get_by_group_id("G-3")
assert row["status"] == "expired" # 标记,不硬删
# 标记后不再出现在 pending 候选
assert not any(c["convert_group_id"] == "G-3" for c in repo.list_expired_candidates(24))
# ── 4. client_request_id 为 None 不受 uk_idem 约束 ───────────────────────
def test_client_request_id_none_not_unique(sqlite_engine):
repo = ConvertRepository(engine=sqlite_engine)
# 两次无幂等键占位都应成功(UNIQUE 允许多个 NULL)
repo.insert_placeholder("G-4", None)
repo.insert_placeholder("G-5", None)
assert repo.get_by_group_id("G-4")["status"] == "pending"
assert repo.get_by_group_id("G-5")["status"] == "pending"
# None 作为查询键永不命中
assert repo.get_by_client_request_id(None) is None
# ── 5. 幂等命中查询 ─────────────────────────────────────────────────────
def test_get_by_client_request_id_hit(sqlite_engine):
repo = ConvertRepository(engine=sqlite_engine)
repo.insert_placeholder("G-6", "REQ-6")
row = repo.get_by_client_request_id("REQ-6")
assert row is not None
assert row["convert_group_id"] == "G-6"
assert repo.get_by_client_request_id("REQ-NOPE") is None
# ── 6. 不与 risk_repository 混职责 ──────────────────────────────────────
def test_no_cross_write_to_risk_tables(sqlite_engine):
"""convert 占位只写 risk_convert_detail,绝不污染 risk_alert(职责隔离)。"""
repo = ConvertRepository(engine=sqlite_engine)
repo.insert_placeholder("G-7", "REQ-7")
with sqlite_engine.connect() as conn:
alert_count = conn.execute(text("SELECT COUNT(*) FROM risk_alert")).scalar_one()
assert alert_count == 0
# 类型隔离:两个仓储是不同类,各自只碰自己的表
assert isinstance(repo, ConvertRepository)
assert not isinstance(repo, RiskRepository)