- OrdersDAO.__init__ 新增 owns_conn=False 参数 - __exit__ 仅在 owns_conn=True 时 close - connect() classmethod 创建的 conn 自动 owns_conn=True - 修复 retention_cleanup 一次命中 ≥2 笔终端态订单时 第二笔起 Cannot operate on a closed database 的 bug - 新增 test_retention_cleanup_apply_anonymizes_multiple_old_orders_in_sequence 锁住多订单连续 anonymize 契约 - runbook §8 新增 T12-D 端到端本地 acceptance 步骤 + 部署前 checklist - CHANGELOG 加 v2.1.1 (2026-06-20) 段
188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from data.orders.dao import OrdersDAO
|
||
from data.orders.models import Order
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def _seed_old_completed_order(
|
||
db_path: str, order_id: str = "GKO-20250101-RETENTION"
|
||
) -> Order:
|
||
order = Order(
|
||
id=order_id,
|
||
source="web",
|
||
service_version="standard",
|
||
amount_cents=9900,
|
||
status="completed",
|
||
customer_name="张家长",
|
||
customer_phone="13800138000",
|
||
customer_wechat="wx-retention",
|
||
candidate_name="张三",
|
||
candidate_province="湖南",
|
||
notes="old completed order",
|
||
created_at="2025-01-01T00:00:00+00:00",
|
||
completed_at="2025-01-02T00:00:00+00:00",
|
||
status_updated_at="2025-01-02T00:00:00+00:00",
|
||
)
|
||
with OrdersDAO.connect(db_path) as dao:
|
||
return dao.create(order, actor="test", reason="seed_old_completed")
|
||
|
||
|
||
def test_retention_cleanup_dry_run_reports_candidates(settings):
|
||
_seed_old_completed_order(settings.orders_db_path)
|
||
|
||
from data.orders.retention_cleanup import run_cleanup
|
||
|
||
result = run_cleanup(
|
||
settings.orders_db_path,
|
||
cutoff_iso="2025-06-30T00:00:00+00:00",
|
||
apply=False,
|
||
)
|
||
assert result.scanned >= 1
|
||
assert result.candidates == 1
|
||
assert result.anonymized == 0
|
||
|
||
|
||
def test_retention_cleanup_apply_anonymizes_old_completed_order(settings):
|
||
_seed_old_completed_order(
|
||
settings.orders_db_path, order_id="GKO-20250101-RETENTION-APPLY"
|
||
)
|
||
|
||
from data.orders.retention_cleanup import run_cleanup
|
||
|
||
result = run_cleanup(
|
||
settings.orders_db_path,
|
||
cutoff_iso="2025-06-30T00:00:00+00:00",
|
||
apply=True,
|
||
)
|
||
assert result.candidates == 1
|
||
assert result.anonymized == 1
|
||
|
||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||
order = dao.get("GKO-20250101-RETENTION-APPLY")
|
||
assert order.customer_phone is None
|
||
assert order.customer_name == "已匿名化"
|
||
|
||
|
||
def test_retention_cleanup_script_prints_summary(settings):
|
||
_seed_old_completed_order(
|
||
settings.orders_db_path, order_id="GKO-20250101-RETENTION-CLI"
|
||
)
|
||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||
proc = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"scripts/gaokao-retention-cleanup.py",
|
||
"--cutoff",
|
||
"2025-06-30T00:00:00+00:00",
|
||
"--dry-run",
|
||
],
|
||
cwd=PROJECT_ROOT,
|
||
text=True,
|
||
capture_output=True,
|
||
env=env,
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert '"cutoff_iso": "2025-06-30T00:00:00+00:00"' in proc.stdout
|
||
assert '"dry_run": true' in proc.stdout
|
||
assert '"candidates": 1' in proc.stdout
|
||
|
||
|
||
def test_retention_cleanup_script_supports_retention_days(settings):
|
||
_seed_old_completed_order(
|
||
settings.orders_db_path, order_id="GKO-20250101-RETENTION-DAYS"
|
||
)
|
||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||
proc = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"scripts/gaokao-retention-cleanup.py",
|
||
"--retention-days",
|
||
"180",
|
||
"--dry-run",
|
||
],
|
||
cwd=PROJECT_ROOT,
|
||
text=True,
|
||
capture_output=True,
|
||
env=env,
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert '"cutoff_iso":' in proc.stdout
|
||
assert '"dry_run": true' in proc.stdout
|
||
assert '"candidates": 1' in proc.stdout
|
||
|
||
|
||
def test_retention_cleanup_apply_anonymizes_multiple_old_orders_in_sequence(
|
||
settings,
|
||
) -> None:
|
||
"""T12-D regression: 服务持有 conn 时,连续 anonymize 多笔订单必须全部成功。
|
||
|
||
历史 bug: ``deletion_service.anonymize_order`` 把 ``self._conn`` 包成
|
||
``OrdersDAO(self._conn)`` 走 with-block,而 ``OrdersDAO.__exit__`` 不区分
|
||
conn 所有权,一律 close。第一个订单执行完就把 service 持有的连接关掉,
|
||
第二个订单开始全部 ``Cannot operate on a closed database``。
|
||
"""
|
||
from data.orders.retention_cleanup import run_cleanup
|
||
|
||
order_ids = [
|
||
"GKO-20250101-RETENTION-MULTI-A",
|
||
"GKO-20250101-RETENTION-MULTI-B",
|
||
"GKO-20250101-RETENTION-MULTI-C",
|
||
]
|
||
for idx, oid in enumerate(order_ids, start=1):
|
||
_seed_old_completed_order(settings.orders_db_path, order_id=oid)
|
||
# 给每个订单一点时间间隔,确保 status_updated_at 不完全相同
|
||
# (cleanup 按 cutoff 命中即可,无需显式 sleep)
|
||
|
||
result = run_cleanup(
|
||
settings.orders_db_path,
|
||
cutoff_iso="2025-06-30T00:00:00+00:00",
|
||
apply=True,
|
||
)
|
||
|
||
assert result.scanned >= 3
|
||
assert result.candidates == 3
|
||
assert result.anonymized == 3, (
|
||
f"连续多笔订单应全部匿名化,实际 anonymized={result.anonymized} "
|
||
f"(历史 bug: 第一个 anonymize 关闭 service.conn 后后续全部失败)"
|
||
)
|
||
|
||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||
for oid in order_ids:
|
||
order = dao.get(oid)
|
||
assert order.customer_phone is None, f"{oid} phone 未被清空"
|
||
assert order.customer_name == "已匿名化", (
|
||
f"{oid} name 未被替换为'已匿名化',实际={order.customer_name!r}"
|
||
)
|
||
|
||
|
||
def test_retention_cleanup_underscore_script_alias_works(settings):
|
||
_seed_old_completed_order(
|
||
settings.orders_db_path, order_id="GKO-20250101-RETENTION-ALIAS"
|
||
)
|
||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||
proc = subprocess.run(
|
||
[
|
||
sys.executable,
|
||
"scripts/gaokao_retention_cleanup.py",
|
||
"--cutoff",
|
||
"2025-06-30T00:00:00+00:00",
|
||
"--dry-run",
|
||
],
|
||
cwd=PROJECT_ROOT,
|
||
text=True,
|
||
capture_output=True,
|
||
env=env,
|
||
check=False,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert '"candidates": 1' in proc.stdout
|