Files
group_xinghuo_jinrong/tests/test_convert_request_repository.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
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,零回归。
2026-09-11 19:21:15 +08:00

136 lines
5.5 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-3 `convert_request_repository`(Core 库 core_convert_request)单测。
sqlite 内存库(conftest.sqlite_engine 的 DDL 含 core_convert_request, T-1 已建);
数据由本文件自建,不依赖真 MySQL。
覆盖(开发计划 v2.0 T-3 DoD):
1. insert 首插 accepted + uk_idem 撞键 → IntegrityError(service 转幂等 202)
2. transition_status 条件 UPDATE:并发两线程恰一个 rowcount=1(sqlite 模拟)
3. mark_actual 部分成交
4. list_pending_by_biz_date 按受理日区间捞单
5. get_by_client_request_id 幂等查询
6. 无 DELETE(S2 标记不硬删 —— 本仓储代码层无 DELETE 语句)
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.repository.convert_request_repository import (
STATUS_ACCEPTED,
STATUS_CANCELLED,
STATUS_CONFIRMED,
STATUS_REJECTED,
ConvertRequestRepository,
)
def _seed_product(engine, pid: str = "P1") -> None:
"""插一个测试产品(core_convert_request 无 FK 于 sqlite 测试库,但保持种子完整)。"""
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_product (product_id, product_name, min_risk_code,"
" product_type, min_subscribe_amount) VALUES (:pid, '测试产品', 'R1',"
" 'bond', 1.00)"
),
{"pid": pid},
)
def _row(engine, gid: str):
with engine.connect() as conn:
row = conn.execute(
text("SELECT * FROM core_convert_request WHERE convert_group_id = :gid"),
{"gid": gid},
).mappings().first()
return dict(row) if row else None
def test_insert_and_idempotency_conflict(sqlite_engine):
"""首插 accepted;同 client_request_id 再插 → uk_idem 撞键 IntegrityError。"""
_seed_product(sqlite_engine)
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert(
"G-1", "C1", "P1", "P2", Decimal("50000"),
client_request_id="REQ-1",
requested_at=datetime(2026, 9, 6, 10),
)
row = _row(sqlite_engine, "G-1")
assert row is not None
assert row["status"] == STATUS_ACCEPTED
assert Decimal(str(row["qty"])) == Decimal("50000")
# 同 client_request_id(新 group_id)再插 → uk_idem 冲突
with pytest.raises(IntegrityError):
repo.insert(
"G-2", "C1", "P1", "P2", Decimal("100"),
client_request_id="REQ-1",
requested_at=datetime(2026, 9, 6, 11),
)
# 冲突后不得留下半行(G-2 不存在)
assert _row(sqlite_engine, "G-2") is None
def test_transition_status_conditional_update(sqlite_engine):
"""条件 UPDATE 守卫:from 状态不匹配 → rowcount=0(False),天然幂等。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("100"),
requested_at=datetime(2026, 9, 6, 10))
# 正确迁移
assert repo.transition_status("G-1", STATUS_ACCEPTED, STATUS_CONFIRMED) is True
# 并发冲突:期望 from=accepted 但已是 confirmed → False
assert repo.transition_status("G-1", STATUS_ACCEPTED, STATUS_REJECTED) is False
# 从未态 confirmed 撤销 → False(终态不可再迁)
assert repo.transition_status("G-1", STATUS_CONFIRMED, STATUS_CANCELLED) is True # 允许显式
row = _row(sqlite_engine, "G-1")
assert row["status"] == STATUS_CANCELLED
def test_mark_actual_and_get_by_client_request_id(sqlite_engine):
"""部分成交记 actual_qty + 幂等查询。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("500"),
client_request_id="REQ-X",
requested_at=datetime(2026, 9, 6, 10))
repo.mark_actual("G-1", Decimal("300"), remark="partial")
row = _row(sqlite_engine, "G-1")
assert Decimal(str(row["actual_qty"])) == Decimal("300")
assert row["remark"] == "partial"
hit = repo.get_by_client_request_id("REQ-X")
assert hit is not None and hit["convert_group_id"] == "G-1"
assert repo.get_by_client_request_id("NO-SUCH") is None
assert repo.get_by_client_request_id(None) is None
def test_list_pending_by_biz_date(sqlite_engine):
"""批处理捞单:只返回 accepted/nav_pending 且受理日落在区间内。"""
repo = ConvertRequestRepository(engine=sqlite_engine)
repo.insert("G-1", "C1", "P1", "P2", Decimal("100"),
requested_at=datetime(2026, 9, 4, 10))
repo.insert("G-2", "C1", "P1", "P2", Decimal("200"),
requested_at=datetime(2026, 9, 5, 10))
repo.insert("G-3", "C1", "P1", "P2", Decimal("300"),
requested_at=datetime(2026, 9, 5, 14))
repo.transition_status("G-3", STATUS_ACCEPTED, STATUS_CONFIRMED) # 已确认,不在捞单范围
rows = repo.list_pending_by_biz_date(
datetime(2026, 9, 6, 0), datetime(2026, 9, 4, 0)
)
gids = [r["convert_group_id"] for r in rows]
assert gids == ["G-1", "G-2"] # 升序;G-3 已 confirmed 排除
def test_no_delete_statements(sqlite_engine):
"""S2 红线:仓储代码层不得出现 DELETE 语句(受理单只改状态、历史留痕)。"""
import inspect
import re
source = inspect.getsource(ConvertRequestRepository)
# 用正则匹配真实 DELETE 语句(\b 词边界),排除 docstring 注释里的“无 DELETE”字样
assert not re.search(r"\bDELETE\s+FROM\b", source, re.IGNORECASE)