"""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)