"""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 且受理日落在区间内的单。 `as_of` 为**闭区间上界**(含当日)—— 与 `list_convert_requests` 同语义(T-7 统一)。 """ 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, 5, 0), datetime(2026, 9, 4, 0)) gids = [r["convert_group_id"] for r in rows] assert gids == ["G-1", "G-2"] # 升序;G-3 已 confirmed 排除 # 边界:上界取 9-4 时**不含** 9-5 的单(闭区间上界不是「无限上界」) only_4th = repo.list_pending_by_biz_date(datetime(2026, 9, 4, 0), datetime(2026, 9, 4, 0)) assert [r["convert_group_id"] for r in only_4th] == ["G-1"] def test_reject_writes_reason_and_is_guarded(sqlite_engine): """`reject`:accepted → rejected + remark 落拒绝原因;条件 UPDATE 防重复。""" repo = ConvertRequestRepository(engine=sqlite_engine) repo.insert("G-R1", "C1", "P1", "P2", Decimal("100"), requested_at=datetime(2026, 9, 4, 10)) assert repo.reject("G-R1", STATUS_ACCEPTED, "SUITABILITY_MISMATCH") is True row = _row(sqlite_engine, "G-R1") assert row["status"] == STATUS_REJECTED assert row["remark"] == "SUITABILITY_MISMATCH" # 已 rejected → 再用 accepted 作 from 迁移必然 False(并发守卫) assert repo.reject("G-R1", STATUS_ACCEPTED, "AGAIN") is False # 拒绝后不再被批处理捞到(终态不占用) assert repo.list_pending_by_biz_date(datetime(2026, 9, 4, 0), datetime(2026, 9, 1, 0)) == [] 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)