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,零回归。
136 lines
5.5 KiB
Python
136 lines
5.5 KiB
Python
"""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) |