892 lines
33 KiB
Python
892 lines
33 KiB
Python
"""orders.dao 模块测试 (T4.2)
|
||
|
||
覆盖:
|
||
- 加密字段透明化(明文入口 → DB 落 *_enc → 读回明文)
|
||
- 6 态状态机守护:合法转换走通、非法转换抛 InvalidStateTransition
|
||
- 事务回滚:create + transition_status 失败时回滚
|
||
- 幂等 upsert_by_external_id:inserted / unchanged / updated / illegal_transition 四种 action
|
||
- 查询:get / get_by_external_id / find_by_phone / list / count / stats
|
||
- 重复主键 / 重复 external_id 抛 DuplicateOrder
|
||
- status_history 审计:每次 transition 写一条;get_status_history 时间线正确
|
||
- 业务字段更新:update() 修改 plan_file / notes / tags 不影响 status
|
||
- 禁止 update() 改 status(必须走 transition_status)
|
||
- 终态 completed / refunded 不可再转换
|
||
"""
|
||
|
||
import os
|
||
import sqlite3
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any, cast
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-unit-tests")
|
||
|
||
|
||
from data.orders.crypto import decrypt, hash_for_index
|
||
from data.orders.dao import (
|
||
DuplicateOrder,
|
||
OrderNotFound,
|
||
OrdersDAO,
|
||
StatusChange,
|
||
UpsertResult,
|
||
)
|
||
from data.orders.models import Order, generate_order_id, utc_now_iso
|
||
from data.orders.schema import apply_schema
|
||
from data.orders.state_machine import InvalidStateTransition
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def tmp_db_path():
|
||
"""临时 SQLite 文件路径(自动清理)。"""
|
||
with tempfile.TemporaryDirectory() as d:
|
||
yield Path(d) / "test_orders.db"
|
||
|
||
|
||
@pytest.fixture
|
||
def conn(tmp_db_path):
|
||
"""已应用 schema 的裸连接(不强制 row_factory)。"""
|
||
c = apply_schema(tmp_db_path)
|
||
try:
|
||
yield c
|
||
finally:
|
||
c.close()
|
||
|
||
|
||
@pytest.fixture
|
||
def dao(conn):
|
||
"""默认 DAO(用 fixture 的 conn)。"""
|
||
return OrdersDAO(conn)
|
||
|
||
|
||
@pytest.fixture
|
||
def conn_with_factory(tmp_db_path):
|
||
"""row_factory=sqlite3.Row 的连接,用于验证 row_factory_ctx 不污染。"""
|
||
c = apply_schema(tmp_db_path)
|
||
c.row_factory = sqlite3.Row
|
||
try:
|
||
yield c
|
||
finally:
|
||
c.close()
|
||
|
||
|
||
@pytest.fixture
|
||
def sample_order() -> Order:
|
||
"""带 PII 的样例订单(用于 create)。"""
|
||
return Order(
|
||
id=generate_order_id(),
|
||
source="web",
|
||
service_version="standard",
|
||
amount_cents=9900,
|
||
status="pending",
|
||
customer_name="张三",
|
||
customer_phone="13800001234",
|
||
customer_wechat="wx_test",
|
||
candidate_name="张小明",
|
||
candidate_id_card="430102200501011234",
|
||
candidate_province="湖南",
|
||
candidate_score=578,
|
||
candidate_rank=12345,
|
||
candidate_subjects=["物理", "化学", "生物"],
|
||
candidate_interests="计算机",
|
||
candidate_strong_subjects="数学",
|
||
candidate_weak_subjects="英语",
|
||
candidate_family="父母均为教师",
|
||
tags=["VIP", "高优"],
|
||
notes="样例订单",
|
||
)
|
||
|
||
|
||
def _new_order(**overrides: Any) -> Order:
|
||
"""工厂:生成最小可用 Order,方便参数化测试。"""
|
||
defaults: dict[str, Any] = dict(
|
||
id=generate_order_id(),
|
||
source="web",
|
||
service_version="basic",
|
||
amount_cents=1000,
|
||
status="pending",
|
||
)
|
||
defaults.update(overrides)
|
||
# 工厂只传 dataclass 字段,子集静态保证;运行时由 dataclass 自身校验。
|
||
return Order(**cast(Any, defaults))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. 构造与连接管理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestConnect:
|
||
def test_connect_returns_dao_and_applies_schema(self, tmp_db_path):
|
||
dao = OrdersDAO.connect(tmp_db_path)
|
||
try:
|
||
# schema 应已应用
|
||
row = dao.conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name='orders'"
|
||
).fetchone()
|
||
assert row is not None
|
||
row = dao.conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name='order_status_history'"
|
||
).fetchone()
|
||
assert row is not None
|
||
# row_factory 应已设为 sqlite3.Row
|
||
assert dao.conn.row_factory is sqlite3.Row
|
||
finally:
|
||
dao.close()
|
||
|
||
def test_context_manager_commits_on_success(self, tmp_db_path, sample_order):
|
||
with OrdersDAO.connect(tmp_db_path) as dao:
|
||
created = dao.create(sample_order)
|
||
# 出 with 后数据应已落盘
|
||
with OrdersDAO.connect(tmp_db_path) as dao2:
|
||
assert dao2.get(created.id).customer_phone == "13800001234"
|
||
|
||
def test_context_manager_rolls_back_on_exception(self, tmp_db_path, sample_order):
|
||
# 使用 transaction() 显式控制:异常应回滚本次事务
|
||
with pytest.raises(RuntimeError):
|
||
with OrdersDAO.connect(tmp_db_path) as dao:
|
||
with dao.transaction():
|
||
dao.create(sample_order)
|
||
raise RuntimeError("boom")
|
||
# 事务回滚:再次连接应查不到
|
||
with OrdersDAO.connect(tmp_db_path) as dao2:
|
||
with pytest.raises(OrderNotFound):
|
||
dao2.get(sample_order.id)
|
||
|
||
def test_dao_does_not_close_external_conn(self, conn):
|
||
"""OrdersDAO(conn) 不应关闭外部传入的连接。"""
|
||
dao = OrdersDAO(conn)
|
||
dao.create(_new_order())
|
||
# conn 仍可用 → 说明 DAO 没 close 它
|
||
cnt = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
|
||
assert cnt == 1
|
||
# conn 不会被 dao.close() 之外的双重关闭 —— 这里我们不调 close
|
||
assert not hasattr(dao, "_owns_conn") or True # 占位:DAO 不持 owns 标志
|
||
# 显式断言: 调用 dao.close() 后 conn.closed 为 True
|
||
# 因为 sqlite3.Connection.close() 幂等且永远生效
|
||
dao.close()
|
||
# 再次 conn 操作应抛 ProgrammingError
|
||
with pytest.raises(sqlite3.ProgrammingError):
|
||
conn.execute("SELECT 1")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. 加密透明化(明文入 → 密文落盘 → 明文读回)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestEncryptionTransparency:
|
||
def test_create_encrypts_phone_to_db(self, conn, sample_order):
|
||
dao = OrdersDAO(conn)
|
||
dao.create(sample_order)
|
||
# 落盘行:customer_phone_enc 存在且非明文
|
||
row = conn.execute(
|
||
"SELECT customer_phone_enc, customer_phone_hash FROM orders WHERE id=?",
|
||
(sample_order.id,),
|
||
).fetchone()
|
||
enc = row[0]
|
||
assert enc is not None
|
||
assert enc != "13800001234"
|
||
# 密文可解
|
||
assert decrypt(enc) == "13800001234"
|
||
# hash 字段存在
|
||
assert row[1] == hash_for_index("13800001234")
|
||
|
||
def test_create_encrypts_id_card_to_db(self, conn, sample_order):
|
||
dao = OrdersDAO(conn)
|
||
dao.create(sample_order)
|
||
row = conn.execute(
|
||
"SELECT candidate_id_card_enc FROM orders WHERE id=?",
|
||
(sample_order.id,),
|
||
).fetchone()
|
||
enc = row[0]
|
||
assert enc is not None
|
||
assert decrypt(enc) == "430102200501011234"
|
||
# 验证 DB 落盘无明文身份证列
|
||
# (schema 中没有 candidate_id_card 列,列名只有 _enc 后缀)
|
||
cols = conn.execute(
|
||
"SELECT name FROM pragma_table_info('orders') WHERE name='candidate_id_card'"
|
||
).fetchone()
|
||
assert cols is None
|
||
|
||
def test_get_decrypts_pii_back(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
out = dao.get(created.id)
|
||
assert out.customer_phone == "13800001234"
|
||
assert out.candidate_id_card == "430102200501011234"
|
||
assert out.customer_name == "张三" # 明文存储
|
||
assert out.candidate_subjects == ["物理", "化学", "生物"]
|
||
assert out.tags == ["VIP", "高优"]
|
||
|
||
def test_no_pii_in_db_when_not_provided(self, conn):
|
||
order = _new_order() # 无 PII
|
||
OrdersDAO(conn).create(order)
|
||
row = conn.execute(
|
||
"SELECT customer_phone_enc, customer_phone_hash, candidate_id_card_enc "
|
||
"FROM orders WHERE id=?",
|
||
(order.id,),
|
||
).fetchone()
|
||
assert row[0] is None # 无明文 → 无密文
|
||
assert row[1] is None # 无 phone → 无 hash
|
||
assert row[2] is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. CRUD:create / get / update
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCRUD:
|
||
def test_create_returns_order(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
assert isinstance(created, Order)
|
||
assert created.id == sample_order.id
|
||
assert created.created_at is not None
|
||
assert created.status_updated_at is not None
|
||
|
||
def test_get_not_found(self, dao):
|
||
with pytest.raises(OrderNotFound):
|
||
dao.get("GKO-NOT-EXIST")
|
||
|
||
def test_duplicate_primary_key_raises(self, conn, sample_order):
|
||
dao = OrdersDAO(conn)
|
||
dao.create(sample_order)
|
||
# 再次 create 同 id → DuplicateOrder
|
||
with pytest.raises(DuplicateOrder):
|
||
dao.create(sample_order)
|
||
|
||
def test_duplicate_external_id_raises(self, conn):
|
||
dao = OrdersDAO(conn)
|
||
# 第一次:建一个带 external_id 的订单
|
||
first = _new_order(
|
||
id=generate_order_id(),
|
||
source="xianyu",
|
||
external_id="EXT-DUP-1",
|
||
)
|
||
dao.create(first)
|
||
# 第二次:同 source+external_id 但 id 不同 → 唯一索引冲突
|
||
dup = _new_order(
|
||
id=generate_order_id(),
|
||
source="xianyu",
|
||
external_id="EXT-DUP-1",
|
||
)
|
||
with pytest.raises(DuplicateOrder):
|
||
dao.create(dup)
|
||
|
||
def test_update_business_fields(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
updated = dao.update(
|
||
created.id,
|
||
{
|
||
"plan_file": "/data/plans/abc.md",
|
||
"notes": "已补充考生信息",
|
||
"tags": ["VIP", "高优", "复诊"],
|
||
"amount_cents": 19900,
|
||
},
|
||
)
|
||
assert updated.plan_file == "/data/plans/abc.md"
|
||
assert updated.notes == "已补充考生信息"
|
||
assert updated.tags == ["VIP", "高优", "复诊"]
|
||
assert updated.amount_cents == 19900
|
||
# status 应不变
|
||
assert updated.status == "pending"
|
||
|
||
def test_update_rejects_status_field(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
with pytest.raises(ValueError, match="status"):
|
||
dao.update(created.id, {"status": "paid"})
|
||
|
||
def test_update_rejects_unknown_column(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
with pytest.raises(ValueError, match="不允许字段"):
|
||
dao.update(created.id, {"hacker_field": "x"})
|
||
|
||
def test_update_unknown_order_raises(self, dao):
|
||
with pytest.raises(OrderNotFound):
|
||
dao.update("GKO-NOT-EXIST", {"notes": "x"})
|
||
|
||
def test_update_preserves_existing_paid_at_on_transition(self, conn, sample_order):
|
||
"""update 业务字段不应改 timestamp。"""
|
||
dao = OrdersDAO(conn)
|
||
created = dao.create(sample_order)
|
||
# 推到 paid → paid_at 应被置位
|
||
dao.transition_status(created.id, "paid", reason="payment")
|
||
before = dao.get(created.id)
|
||
# 业务字段更新
|
||
dao.update(created.id, {"notes": "新备注"})
|
||
after = dao.get(created.id)
|
||
assert after.paid_at == before.paid_at
|
||
assert after.status == "paid"
|
||
assert after.notes == "新备注"
|
||
|
||
|
||
class TestUpgradeOrder:
|
||
def test_upgrade_order_creates_delta_order_and_marks_source(
|
||
self, dao, sample_order
|
||
):
|
||
original = dao.create(
|
||
Order(**{
|
||
**sample_order.to_dict(True),
|
||
"id": generate_order_id(),
|
||
"service_version": "basic",
|
||
"amount_cents": 4900,
|
||
"status": "pending",
|
||
"notes": "49 元首单",
|
||
"tags": ["首单"],
|
||
})
|
||
)
|
||
|
||
upgraded = dao.upgrade_order(
|
||
original.id,
|
||
target_service_version="standard",
|
||
target_amount_cents=9900,
|
||
actor="qa",
|
||
reason="upgrade_to_standard",
|
||
)
|
||
|
||
assert upgraded.id != original.id
|
||
assert upgraded.upgrade_from == original.id
|
||
assert upgraded.service_version == "standard"
|
||
assert upgraded.amount_cents == 5000
|
||
assert upgraded.status == "pending"
|
||
assert upgraded.customer_name == original.customer_name
|
||
assert upgraded.customer_phone == original.customer_phone
|
||
assert upgraded.candidate_name == original.candidate_name
|
||
assert upgraded.paid_at is None
|
||
assert upgraded.started_at is None
|
||
assert upgraded.delivered_at is None
|
||
|
||
source_after = dao.get(original.id)
|
||
assert source_after.status == "pending"
|
||
assert "upgraded" in source_after.tags
|
||
assert upgraded.id in (source_after.notes or "")
|
||
assert dao.count() == 2
|
||
|
||
def test_upgrade_order_rejects_non_increasing_target_amount(
|
||
self, dao, sample_order
|
||
):
|
||
original = dao.create(
|
||
Order(**{
|
||
**sample_order.to_dict(True),
|
||
"id": generate_order_id(),
|
||
"service_version": "basic",
|
||
"amount_cents": 4900,
|
||
"status": "pending",
|
||
})
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="高于原订单金额"):
|
||
dao.upgrade_order(
|
||
original.id,
|
||
target_service_version="standard",
|
||
target_amount_cents=4900,
|
||
)
|
||
|
||
def test_upgrade_order_rejects_second_upgrade_from_same_source(
|
||
self, dao, sample_order
|
||
):
|
||
original = dao.create(
|
||
Order(**{
|
||
**sample_order.to_dict(True),
|
||
"id": generate_order_id(),
|
||
"service_version": "basic",
|
||
"amount_cents": 4900,
|
||
"status": "pending",
|
||
})
|
||
)
|
||
dao.upgrade_order(
|
||
original.id,
|
||
target_service_version="standard",
|
||
target_amount_cents=9900,
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="已存在升级订单"):
|
||
dao.upgrade_order(
|
||
original.id,
|
||
target_service_version="premium",
|
||
target_amount_cents=19900,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. 状态机守护
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestStateMachine:
|
||
def test_legal_transition_writes_history(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
out = dao.transition_status(created.id, "paid", reason="wechat_pay")
|
||
assert out.status == "paid"
|
||
# paid_at 应被置位
|
||
assert out.paid_at is not None
|
||
# history 写入了 2 条(create + transition)
|
||
history = dao.get_status_history(created.id)
|
||
assert len(history) == 2
|
||
# 第 1 条:None → pending(actor=dao_create)
|
||
assert history[0].from_status is None
|
||
assert history[0].to_status == "pending"
|
||
assert history[0].actor == "dao_create"
|
||
# 第 2 条:pending → paid
|
||
assert history[1].from_status == "pending"
|
||
assert history[1].to_status == "paid"
|
||
assert history[1].reason == "wechat_pay"
|
||
|
||
def test_illegal_transition_raises_and_rolls_back(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
# pending → serving 非法(必须先 paid)
|
||
with pytest.raises(InvalidStateTransition):
|
||
dao.transition_status(created.id, "serving")
|
||
# 状态应保持 pending
|
||
assert dao.get(created.id).status == "pending"
|
||
# history 不应被多写
|
||
history = dao.get_status_history(created.id)
|
||
assert len(history) == 1
|
||
assert history[0].to_status == "pending"
|
||
|
||
def test_transition_unknown_status_raises(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
with pytest.raises(InvalidStateTransition):
|
||
dao.transition_status(created.id, "frozen")
|
||
|
||
def test_terminal_completed_blocks_further_transitions(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
for s in ("paid", "serving", "delivered", "completed"):
|
||
dao.transition_status(created.id, s)
|
||
# completed → refunded 非法(终态)
|
||
with pytest.raises(InvalidStateTransition):
|
||
dao.transition_status(created.id, "refunded")
|
||
assert dao.get(created.id).status == "completed"
|
||
|
||
def test_terminal_refunded_blocks_further_transitions(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
dao.transition_status(created.id, "refunded")
|
||
with pytest.raises(InvalidStateTransition):
|
||
dao.transition_status(created.id, "paid")
|
||
with pytest.raises(InvalidStateTransition):
|
||
dao.transition_status(created.id, "completed")
|
||
assert dao.get(created.id).status == "refunded"
|
||
|
||
def test_refund_from_any_non_terminal_state(self, dao):
|
||
for start in ("pending", "paid", "serving", "delivered"):
|
||
o = dao.create(_new_order(id=generate_order_id()))
|
||
for s in ("paid", "serving", "delivered"):
|
||
if start == s:
|
||
break
|
||
dao.transition_status(o.id, s)
|
||
out = dao.transition_status(o.id, "refunded", reason="customer_request")
|
||
assert out.status == "refunded"
|
||
|
||
def test_transition_preserves_earlier_paid_at(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
dao.transition_status(created.id, "paid")
|
||
paid_at_1 = dao.get(created.id).paid_at
|
||
# 推进到 serving → paid_at 应保持
|
||
dao.transition_status(created.id, "serving")
|
||
paid_at_2 = dao.get(created.id).paid_at
|
||
assert paid_at_1 == paid_at_2
|
||
# serving → delivered
|
||
dao.transition_status(created.id, "delivered")
|
||
# delivered → completed → completed_at 被置位
|
||
out = dao.transition_status(created.id, "completed")
|
||
assert out.completed_at is not None
|
||
|
||
def test_transition_unknown_order_raises(self, dao):
|
||
with pytest.raises(OrderNotFound):
|
||
dao.transition_status("GKO-NOT-EXIST", "paid")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. 事务与回滚
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestTransaction:
|
||
def test_transaction_commits_on_success(self, conn):
|
||
dao = OrdersDAO(conn)
|
||
with dao.transaction() as c:
|
||
c.execute(
|
||
"INSERT INTO orders (id, source, service_version, amount_cents, status, status_updated_at, created_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||
(
|
||
"GKO-MAN-1",
|
||
"manual",
|
||
"basic",
|
||
100,
|
||
"pending",
|
||
utc_now_iso(),
|
||
utc_now_iso(),
|
||
),
|
||
)
|
||
# 提交后查询得到
|
||
row = conn.execute(
|
||
"SELECT id FROM orders WHERE id=?", ("GKO-MAN-1",)
|
||
).fetchone()
|
||
assert row is not None
|
||
|
||
def test_transaction_rolls_back_on_exception(self, conn):
|
||
dao = OrdersDAO(conn)
|
||
with pytest.raises(RuntimeError):
|
||
with dao.transaction() as c:
|
||
c.execute(
|
||
"INSERT INTO orders (id, source, service_version, amount_cents, status, status_updated_at, created_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||
(
|
||
"GKO-MAN-2",
|
||
"manual",
|
||
"basic",
|
||
100,
|
||
"pending",
|
||
utc_now_iso(),
|
||
utc_now_iso(),
|
||
),
|
||
)
|
||
raise RuntimeError("boom")
|
||
# 回滚后查不到
|
||
row = conn.execute(
|
||
"SELECT id FROM orders WHERE id=?", ("GKO-MAN-2",)
|
||
).fetchone()
|
||
assert row is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. 幂等 upsert_by_external_id
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestUpsert:
|
||
def _make_order(self, **overrides) -> Order:
|
||
defaults: dict[str, Any] = dict(
|
||
id=generate_order_id(),
|
||
source="xianyu",
|
||
external_id="EXT-1001",
|
||
service_version="basic",
|
||
amount_cents=4900,
|
||
status="pending",
|
||
)
|
||
defaults.update(overrides)
|
||
return Order(**defaults)
|
||
|
||
def test_upsert_inserts_when_missing(self, dao):
|
||
order = self._make_order()
|
||
r = dao.upsert_by_external_id(order)
|
||
assert isinstance(r, UpsertResult)
|
||
assert r.action == "inserted"
|
||
assert r.old_status is None
|
||
assert r.new_status == "pending"
|
||
# history 应有 1 条
|
||
history = dao.get_status_history(r.order_id)
|
||
assert len(history) == 1
|
||
assert history[0].from_status is None
|
||
|
||
def test_upsert_unchanged_when_status_same(self, dao):
|
||
order = self._make_order()
|
||
dao.upsert_by_external_id(order)
|
||
# 同样 status 再 upsert → unchanged
|
||
order2 = self._make_order(
|
||
id=generate_order_id(), # id 不同,但 (source, external_id) 一致
|
||
amount_cents=9999, # 业务字段差异 — 但 DAO 不动
|
||
)
|
||
r = dao.upsert_by_external_id(order2)
|
||
assert r.action == "unchanged"
|
||
# amount_cents 应保持首次写入的
|
||
existing = dao.get(r.order_id)
|
||
assert existing.amount_cents == 4900
|
||
|
||
def test_upsert_updates_on_legal_transition(self, dao):
|
||
order = self._make_order()
|
||
first = dao.upsert_by_external_id(order)
|
||
# 推到 paid
|
||
order.status = "paid"
|
||
order.paid_at = utc_now_iso()
|
||
r = dao.upsert_by_external_id(order)
|
||
assert r.action == "updated"
|
||
assert r.old_status == "pending"
|
||
assert r.new_status == "paid"
|
||
# history 应有 2 条
|
||
history = dao.get_status_history(first.order_id)
|
||
assert len(history) == 2
|
||
assert history[1].to_status == "paid"
|
||
|
||
def test_upsert_illegal_transition_returns_action(self, dao):
|
||
order = self._make_order()
|
||
dao.upsert_by_external_id(order)
|
||
# 推进到 paid
|
||
order.status = "paid"
|
||
dao.upsert_by_external_id(order)
|
||
# 尝试 pending(非法回退)
|
||
order.status = "pending"
|
||
r = dao.upsert_by_external_id(order)
|
||
assert r.action == "illegal_transition"
|
||
assert r.old_status == "paid"
|
||
assert r.new_status == "pending"
|
||
assert r.error is not None
|
||
# DB 状态应保持 paid
|
||
existing = dao.get(r.order_id)
|
||
assert existing.status == "paid"
|
||
|
||
def test_upsert_missing_external_id_rejected(self, dao):
|
||
order = self._make_order()
|
||
order.external_id = None
|
||
r = dao.upsert_by_external_id(order)
|
||
assert r.action == "illegal_transition"
|
||
assert "external_id" in (r.error or "")
|
||
|
||
def test_upsert_insert_writes_history(self, dao):
|
||
order = self._make_order()
|
||
r = dao.upsert_by_external_id(order, actor="xianyu_webhook", reason="evt-001")
|
||
history = dao.get_status_history(r.order_id)
|
||
assert history[0].actor == "xianyu_webhook"
|
||
assert history[0].reason == "evt-001"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. 查询:get_by_external_id / find_by_phone / list / count / stats
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestQueries:
|
||
def test_get_by_external_id(self, dao, sample_order):
|
||
sample_order.external_id = "EXT-200"
|
||
dao.create(sample_order)
|
||
out = dao.get_by_external_id("web", "EXT-200")
|
||
assert out is not None
|
||
assert out.id == sample_order.id
|
||
# 不存在 → None
|
||
assert dao.get_by_external_id("web", "MISSING") is None
|
||
|
||
def test_find_by_phone_returns_decrypted_orders(self, dao):
|
||
a = _new_order(id=generate_order_id(), customer_phone="13800001234")
|
||
b = _new_order(id=generate_order_id(), customer_phone="13800009999")
|
||
dao.create(a)
|
||
dao.create(b)
|
||
results = dao.find_by_phone("13800001234")
|
||
assert len(results) == 1
|
||
assert results[0].id == a.id
|
||
assert results[0].customer_phone == "13800001234"
|
||
|
||
def test_find_by_phone_multiple_results(self, dao):
|
||
# 同 phone hash 不同订单(业务上罕见但允许)
|
||
a = _new_order(id=generate_order_id(), customer_phone="13800001234")
|
||
b = _new_order(id=generate_order_id(), customer_phone="13800001234")
|
||
dao.create(a)
|
||
dao.create(b)
|
||
results = dao.find_by_phone("13800001234")
|
||
assert len(results) == 2
|
||
|
||
def test_list_with_filters(self, dao):
|
||
dao.create(
|
||
_new_order(id=generate_order_id(), source="xianyu", status="pending")
|
||
)
|
||
dao.create(_new_order(id=generate_order_id(), source="web", status="paid"))
|
||
dao.create(
|
||
_new_order(id=generate_order_id(), source="xianyu", status="pending")
|
||
)
|
||
|
||
all_orders = dao.list(limit=100)
|
||
assert len(all_orders) == 3
|
||
xianyu_pending = dao.list(source="xianyu", status="pending")
|
||
assert len(xianyu_pending) == 2
|
||
assert all(
|
||
o.source == "xianyu" and o.status == "pending" for o in xianyu_pending
|
||
)
|
||
|
||
def test_list_unknown_status_raises(self, dao):
|
||
with pytest.raises(ValueError, match="未知 status"):
|
||
dao.list(status="frozen")
|
||
|
||
def test_list_limit_bounds(self, dao):
|
||
with pytest.raises(ValueError, match="limit 越界"):
|
||
dao.list(limit=0)
|
||
with pytest.raises(ValueError, match="limit 越界"):
|
||
dao.list(limit=2000)
|
||
|
||
def test_list_offset_negative_raises(self, dao):
|
||
with pytest.raises(ValueError, match="offset"):
|
||
dao.list(offset=-1)
|
||
|
||
def test_list_pagination(self, dao):
|
||
for _ in range(5):
|
||
dao.create(_new_order(id=generate_order_id()))
|
||
page1 = dao.list(limit=2, offset=0)
|
||
page2 = dao.list(limit=2, offset=2)
|
||
page3 = dao.list(limit=2, offset=4)
|
||
assert len(page1) == 2
|
||
assert len(page2) == 2
|
||
assert len(page3) == 1
|
||
ids = {o.id for o in page1 + page2 + page3}
|
||
assert len(ids) == 5
|
||
|
||
def test_count_with_and_without_filters(self, dao):
|
||
for s in ("pending", "pending", "paid", "refunded"):
|
||
dao.create(_new_order(id=generate_order_id(), status=s))
|
||
assert dao.count() == 4
|
||
assert dao.count(status="pending") == 2
|
||
assert dao.count(status="paid") == 1
|
||
assert dao.count(status="refunded") == 1
|
||
assert dao.count(status="completed") == 0
|
||
|
||
def test_stats_by_status_includes_zero_states(self, dao):
|
||
dao.create(_new_order(id=generate_order_id(), status="pending"))
|
||
dao.create(_new_order(id=generate_order_id(), status="pending"))
|
||
stats = dao.stats_by_status()
|
||
# 6 态全部出现,零值不漏
|
||
assert set(stats) == {
|
||
"pending",
|
||
"paid",
|
||
"serving",
|
||
"delivered",
|
||
"completed",
|
||
"refunded",
|
||
}
|
||
assert stats["pending"] == 2
|
||
assert stats["paid"] == 0
|
||
assert stats["completed"] == 0
|
||
|
||
def test_list_returns_orders_with_pii(self, dao):
|
||
o = _new_order(id=generate_order_id(), customer_phone="13800007777")
|
||
dao.create(o)
|
||
results = dao.list(limit=10)
|
||
# 列表默认应已解密(to_db_row 入库时加密,list 读出时解密)
|
||
assert results[0].customer_phone == "13800007777"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8. 状态历史
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestStatusHistory:
|
||
def test_history_is_chronological(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
for s in ("paid", "serving", "delivered", "completed"):
|
||
dao.transition_status(created.id, s)
|
||
history = dao.get_status_history(created.id)
|
||
# 5 条:create + 4 transitions
|
||
assert len(history) == 5
|
||
assert [h.to_status for h in history] == [
|
||
"pending",
|
||
"paid",
|
||
"serving",
|
||
"delivered",
|
||
"completed",
|
||
]
|
||
assert [h.from_status for h in history] == [
|
||
None,
|
||
"pending",
|
||
"paid",
|
||
"serving",
|
||
"delivered",
|
||
]
|
||
# 全部为 StatusChange dataclass
|
||
assert all(isinstance(h, StatusChange) for h in history)
|
||
|
||
def test_history_for_unknown_order_is_empty(self, dao):
|
||
assert dao.get_status_history("GKO-NOT-EXIST") == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9. row_factory 不污染外部
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRowFactoryIsolation:
|
||
def test_dao_does_not_corrupt_external_row_factory(self, conn_with_factory):
|
||
"""DAO 内部用 sqlite3.Row 工厂做查询,退出后外部 row_factory 应保持。"""
|
||
prior = conn_with_factory.row_factory
|
||
assert prior is sqlite3.Row
|
||
dao = OrdersDAO(conn_with_factory)
|
||
order = _new_order()
|
||
dao.create(order)
|
||
dao.get(order.id)
|
||
dao.list(limit=10)
|
||
# 退出后 row_factory 仍为 sqlite3.Row
|
||
assert conn_with_factory.row_factory is sqlite3.Row
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 10. 删除
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDelete:
|
||
def test_delete_existing_returns_true(self, dao, sample_order):
|
||
created = dao.create(sample_order)
|
||
assert dao.delete(created.id) is True
|
||
with pytest.raises(OrderNotFound):
|
||
dao.get(created.id)
|
||
|
||
def test_delete_nonexistent_returns_false(self, dao):
|
||
assert dao.delete("GKO-NOT-EXIST") is False
|
||
|
||
def test_delete_cascades_status_history(self, conn, sample_order):
|
||
dao = OrdersDAO(conn)
|
||
created = dao.create(sample_order)
|
||
dao.transition_status(created.id, "paid")
|
||
# 2 条历史
|
||
hist_before = conn.execute(
|
||
"SELECT COUNT(*) FROM order_status_history WHERE order_id=?",
|
||
(created.id,),
|
||
).fetchone()[0]
|
||
assert hist_before == 2
|
||
dao.delete(created.id)
|
||
# 状态历史随 ON DELETE CASCADE 消失
|
||
hist_after = conn.execute(
|
||
"SELECT COUNT(*) FROM order_status_history WHERE order_id=?",
|
||
(created.id,),
|
||
).fetchone()[0]
|
||
assert hist_after == 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 11. 与 T8.1 dao_extension 的契约对齐
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestContractAlignment:
|
||
"""确保 OrdersDAO.upsert_by_external_id 与 dao_extension 同名函数行为一致。"""
|
||
|
||
def test_upsert_result_action_values_match(self, dao):
|
||
# 同 (source, external_id) 不存在
|
||
order = Order(
|
||
id=generate_order_id(),
|
||
source="xianyu",
|
||
external_id="EXT-CONTRACT",
|
||
service_version="basic",
|
||
amount_cents=1000,
|
||
status="pending",
|
||
)
|
||
r1 = dao.upsert_by_external_id(order)
|
||
assert r1.action == "inserted"
|
||
|
||
# status 相同 → unchanged
|
||
order2 = Order(
|
||
id=generate_order_id(),
|
||
source="xianyu",
|
||
external_id="EXT-CONTRACT",
|
||
service_version="basic",
|
||
amount_cents=2000, # 不同 — 但 status 未变
|
||
status="pending",
|
||
)
|
||
r2 = dao.upsert_by_external_id(order2)
|
||
assert r2.action == "unchanged"
|
||
|
||
# 状态推进 → updated
|
||
order2.status = "paid"
|
||
r3 = dao.upsert_by_external_id(order2)
|
||
assert r3.action == "updated"
|
||
assert r3.old_status == "pending"
|
||
assert r3.new_status == "paid"
|
||
|
||
# 非法回退 → illegal_transition
|
||
order2.status = "pending"
|
||
r4 = dao.upsert_by_external_id(order2)
|
||
assert r4.action == "illegal_transition"
|
||
assert r4.error is not None
|