Files
gaokao-volunteer-system/data/orders/tests/test_dao.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

892 lines
33 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.
"""orders.dao 模块测试 (T4.2)
覆盖:
- 加密字段透明化(明文入口 → DB 落 *_enc → 读回明文)
- 6 态状态机守护:合法转换走通、非法转换抛 InvalidStateTransition
- 事务回滚create + transition_status 失败时回滚
- 幂等 upsert_by_external_idinserted / 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. CRUDcreate / 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 → pendingactor=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