AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段: - T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar / core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言 - T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填 - T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法 + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导) - T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口 (旧三方法标 Deprecated,T-7 后删) - T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key - T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延 →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水) + tests/test_convert_accept.py(16 用例) + scripts/dev/verify_convert_accept.py(真库 36/36 一致) + trading_calendar.py 纯函数包(R-5)+ 21 用例 T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库 的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。 基线:798 passed / 10 skipped,零回归。
188 lines
8.5 KiB
Python
188 lines
8.5 KiB
Python
"""T-4 `convert_repository`(agent 侧 risk_convert_detail · 语义收窄)单测。
|
||
|
||
sqlite 内存库(conftest.sqlite_engine 含 agent 库表 + risk_convert_detail);
|
||
数据由本文件经仓储自建,不依赖真 MySQL。
|
||
|
||
T+1 模型下 `risk_convert_detail.status` 5 值 = **agent 写入进度**(PRD §4.1):
|
||
pending=待写入 / completed=详情与审计已写 / failed=写失败待补偿 /
|
||
cancelled / expired = 保留值;**业务状态一律以 `core_convert_request.status` 6 态为准**。
|
||
|
||
覆盖(开发计划 v2.0 T-4 DoD):
|
||
1. `sync_mirror` 受理 ④ pending:INSERT 镜像(estimated=0)+ 行不存在也能写
|
||
2. `sync_mirror` 确认 ② completed:幂等重写(同 group_id 两次调用收敛)
|
||
3. `sync_mirror` 补偿 ④ failed → completed:重写覆盖
|
||
4. 撤单不推 cancelled:`sync_mirror` 只接受 pending/completed/failed(保留值不在写入面)
|
||
5. 状态迁移逻辑无残留:agent 侧不自主判定(业务状态权威在 Core)
|
||
6. 不与 risk_repository 混职责(convert 写操作不动 risk_alert)
|
||
7. v1.0 遗留方法(insert_placeholder/complete_convert/mark_failed)标注 Deprecated 但保留
|
||
|
||
(v1.0 的占位→completed/failed/expired 生命周期测试已随 T-4 语义收窄改写/删除——
|
||
`sync_mirror` 为唯一新语义写入口。)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
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 _row(engine, gid: str) -> dict | None:
|
||
with engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT * FROM risk_convert_detail WHERE convert_group_id = :gid"),
|
||
{"gid": gid},
|
||
).mappings().first()
|
||
return dict(row) if row else None
|
||
|
||
|
||
# ── 1. 受理 ④ pending 镜像(T-6 调用点) ────────────────────────────────
|
||
|
||
def test_sync_mirror_pending_insert(sqlite_engine):
|
||
"""受理落地 Core 后:镜像写 pending(estimated=0 真实请求,非预估单)。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
repo.sync_mirror("G-1", "pending", client_request_id="REQ-1")
|
||
|
||
row = _row(sqlite_engine, "G-1")
|
||
assert row is not None
|
||
assert row["status"] == "pending"
|
||
assert row["estimated"] == 0
|
||
assert row["client_request_id"] == "REQ-1"
|
||
# 受理阶段无确认详情
|
||
assert row["out_trade_id"] is None
|
||
assert row["in_trade_id"] is None
|
||
|
||
|
||
# ── 2. 确认 ② completed 镜像(T-7 调用点) ──────────────────────────────
|
||
|
||
def test_sync_mirror_completed_upsert(sqlite_engine):
|
||
"""确认完成后:completed 镜像带全部折算详情;同 group_id 二次调用幂等重写。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
# 先受理 pending(模拟 T-6 已写)
|
||
repo.sync_mirror("G-2", "pending", client_request_id="REQ-2")
|
||
# 确认 completed(T-7;同 group_id 二次调用 → UPDATE 重写,不炸)
|
||
repo.sync_mirror(
|
||
"G-2",
|
||
"completed",
|
||
out_trade_id="TRD-OUT-2",
|
||
in_trade_id="TRD-IN-2",
|
||
related_trade_id="TRD-OUT-2",
|
||
nav=Decimal("1.3604"),
|
||
nav_date=date(2026, 9, 9),
|
||
fee_amount=Decimal("612.18"),
|
||
hold_days_min=3,
|
||
hold_days_max=180,
|
||
nav_stale=False,
|
||
)
|
||
|
||
row = _row(sqlite_engine, "G-2")
|
||
assert row["status"] == "completed"
|
||
assert row["out_trade_id"] == "TRD-OUT-2"
|
||
assert row["in_trade_id"] == "TRD-IN-2"
|
||
assert row["related_trade_id"] == "TRD-OUT-2"
|
||
assert row["nav"] == pytest.approx(1.3604) # Decimal→float 绑定,读回一致
|
||
assert row["fee_amount"] == pytest.approx(612.18)
|
||
assert date.fromisoformat(str(row["nav_date"])) == date(2026, 9, 9)
|
||
assert row["hold_days_min"] == 3
|
||
assert row["hold_days_max"] == 180
|
||
assert row["nav_stale"] == 0
|
||
assert row["client_request_id"] == "REQ-2" # COALESCE 保旧值
|
||
|
||
|
||
# ── 3. 补偿 ④ failed → completed(T-12 调用点) ─────────────────────────
|
||
|
||
def test_sync_mirror_failed_then_completed(sqlite_engine):
|
||
"""补偿:agent 附加写入失败 → failed 镜像;补偿成功后重写 completed。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
repo.sync_mirror("G-3", "pending", client_request_id="REQ-3")
|
||
# 写入失败 → 置 failed(触发者之一,架构 §5.4)
|
||
repo.sync_mirror("G-3", "failed")
|
||
assert _row(sqlite_engine, "G-3")["status"] == "failed"
|
||
# 补偿成功(rebuild_alerts 仅凭 Core 补写)→ 回 completed
|
||
repo.sync_mirror(
|
||
"G-3", "completed",
|
||
out_trade_id="TRD-OUT-3", in_trade_id="TRD-IN-3",
|
||
nav=Decimal("1.3604"), nav_date=date(2026, 9, 9),
|
||
fee_amount=Decimal("612.18"),
|
||
)
|
||
row = _row(sqlite_engine, "G-3")
|
||
assert row["status"] == "completed"
|
||
assert row["out_trade_id"] == "TRD-OUT-3"
|
||
|
||
|
||
# ── 4. 撤单不推 cancelled(保留值不在写入面) ───────────────────────────
|
||
|
||
def test_sync_mirror_rejects_non_progress_status(sqlite_engine):
|
||
"""cancelled/expired 是 agent 进度保留值:sync_mirror 不得直接写入。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
for bad in ("cancelled", "expired"):
|
||
with pytest.raises(ValueError):
|
||
repo.sync_mirror("G-X", bad)
|
||
|
||
|
||
# ── 5. 状态迁移逻辑无残留(业务状态权威在 Core) ─────────────────────────
|
||
|
||
def test_sync_mirror_no_autonomous_state_machine(sqlite_engine):
|
||
"""agent 侧只记录进度,不自主判定业务状态迁移。
|
||
|
||
`sync_mirror` 的 status 参数由调用方(convert_service 读 Core 权威 6 态后)传入,
|
||
本仓储无 `from -> to` 条件迁移逻辑(那是 convert_request_repository 的职责)。
|
||
"""
|
||
import inspect
|
||
|
||
src = inspect.getsource(ConvertRepository)
|
||
# 进度三值之外的业务状态(accepted/nav_pending/confirmed)不得出现在写路径现值
|
||
assert "_STATUS_PENDING" in src or "pending" in src
|
||
# 无 from/to 条件垫片:agent 侧没有「仅当原状态=X 才迁移」的判定
|
||
# (匹配真实定义/调用而非 docstring 里的引用)
|
||
assert "def transition_status" not in src
|
||
assert "self.transition_status(" not in src and "transition_status(" not in src
|
||
|
||
|
||
# ── 6. 不与 risk_repository 混职责 ──────────────────────────────────────
|
||
|
||
def test_no_cross_write_to_risk_tables(sqlite_engine):
|
||
"""进度镜像只写 risk_convert_detail,绝不污染 risk_alert(职责隔离)。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
repo.sync_mirror("G-7", "pending", client_request_id="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)
|
||
|
||
|
||
# ── 7. v1.0 遗留方法保留(T-7 后删除) ──────────────────────────────────
|
||
|
||
def test_v1_legacy_methods_still_exist(sqlite_engine):
|
||
"""T-4 语义收窄不删旧方法(T-7 全量重写才删),v1.0 流回归仍可用。"""
|
||
repo = ConvertRepository(engine=sqlite_engine)
|
||
assert hasattr(repo, "insert_placeholder")
|
||
assert hasattr(repo, "complete_convert")
|
||
assert hasattr(repo, "mark_failed")
|
||
assert hasattr(repo, "list_expired_candidates") # 供 T-12 迁移前巡检沿用
|
||
assert hasattr(repo, "mark_expired")
|
||
# Deprecated 标注在位
|
||
import inspect
|
||
|
||
src = inspect.getsource(ConvertRepository)
|
||
assert "Deprecated" in src
|
||
|
||
|
||
# ── 8. 无 DELETE(S2 红线) ─────────────────────────────────────────────
|
||
|
||
def test_no_delete_statements(sqlite_engine):
|
||
"""S2 红线:仓储代码层不得出现 DELETE 语句(受理单只改状态、历史留痕)。"""
|
||
import inspect
|
||
import re
|
||
|
||
source = inspect.getsource(ConvertRepository)
|
||
# 用正则匹配真实 DELETE 语句(\b 词边界),排除 docstring 注释里的“无 DELETE”字样
|
||
assert not re.search(r"\bDELETE\s+FROM\b", source, re.IGNORECASE)
|