Files
gaokao-volunteer-system/tests/test_delivery_dispatcher.py

421 lines
14 KiB
Python

from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from data.notifications.email_service import DeliveryNotificationService
from data.orders.dao import OrdersDAO
from data.orders.intake_store import IntakeStore
from data.orders.models import Order
from data.payments.service import PaymentService
from data.notifications.dispatcher import DeliveryDispatcher
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _seed_order(
db_path: str,
order_id: str = "GKO-20260614-DISPATCH",
*,
customer_email: str | None = None,
) -> Order:
order = Order(
id=order_id,
source="web",
service_version="standard",
amount_cents=9900,
status="pending",
customer_name="张家长",
customer_phone="13800138000",
customer_email=customer_email,
candidate_name="张三",
candidate_province="湖南",
)
with OrdersDAO.connect(db_path) as dao:
return dao.create(order, actor="test", reason="seed")
def _mark_paid(settings, order: Order) -> None:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
)
checkout = service.create_checkout(order.id, portal_token="portal-token")
payload, headers = service.provider.build_webhook_request(
payment_id=checkout.payment_id,
amount_cents=order.amount_cents,
provider_trade_no=f"MOCK-{order.id}",
)
service.handle_webhook(payload, headers["X-Mock-Signature"])
def _attach_ready_delivery(settings, tmp_path: Path, order_id: str) -> None:
report_path = tmp_path / f"{order_id}-report.html"
pdf_path = tmp_path / f"{order_id}-report.pdf"
report_path.write_text("<h1>ready</h1>", encoding="utf-8")
pdf_path.write_bytes(b"%PDF-1.4\nready\n")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.update(
order_id,
{"audit_report": str(report_path), "pdf_path": str(pdf_path)},
actor="test",
reason="attach_report",
)
dao.transition_status(order_id, "serving", actor="test", reason="processing")
dao.transition_status(
order_id, "delivered", actor="test", reason="report_ready"
)
def test_dispatch_ready_station_event_validates_persisted_payload(
settings, tmp_path
):
order = _seed_order(settings.orders_db_path)
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
from data.notifications.dispatcher import DeliveryDispatcher
dispatcher = DeliveryDispatcher.for_db(settings.orders_db_path)
try:
result = dispatcher.dispatch_ready_events(channel="station")
finally:
dispatcher.close()
assert result.processed == 1
# station channel is local render only; ``delivered`` must be 0.
assert result.delivered == 0
assert result.validated == 1
assert result.failed == 0
assert not hasattr(result, "sent")
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
event = notification_service.list_events(order.id)[0]
finally:
notification_service.close()
assert event.status == "validated"
assert event.attempt_count == 1
payload = json.loads(event.payload_json)
# The portal page consumes ``station_notice`` from the persisted
# payload even though ``station`` does not transition to
# ``delivered``; the dispatcher stashes it during ``mark_validated``.
station_notice = payload.get("station_notice")
assert station_notice is not None
assert station_notice["title"] == "报告已就绪"
assert order.id in station_notice["body"]
def test_dispatch_station_marks_validated_then_delivered(settings, tmp_path):
"""P2-3 lock: ``station`` channel cannot be reported as ``delivered``.
The lifecycle is now ``ready`` -> ``validated``. ``delivered`` is
reserved for channels that actually push the notice externally
(today: ``email``). This test re-asserts the new chain so future
regressions that flatten the lifecycle back to a single ``sent``
step will fail.
"""
order = _seed_order(
settings.orders_db_path,
order_id="GKO-20260614-DISPATCH-STATION-DELIVERED",
)
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
dispatcher = DeliveryDispatcher.for_db(settings.orders_db_path)
try:
result = dispatcher.dispatch_ready_events(channel="station")
finally:
dispatcher.close()
assert result.processed == 1
assert result.validated == 1
assert result.delivered == 0
assert result.failed == 0
assert not hasattr(result, "sent")
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
event = notification_service.list_events(order.id)[0]
finally:
notification_service.close()
assert event.status == "validated"
class _FakeEmailSender:
def __init__(self) -> None:
self.sent: list[dict[str, str]] = []
def send_report_ready(
self, *, recipient: str, order_id: str, subject: str, body: str
) -> dict[str, str]:
payload = {
"recipient": recipient,
"order_id": order_id,
"subject": subject,
"body": body,
}
self.sent.append(payload)
return payload
def test_dispatch_ready_email_event_marks_sent(settings, tmp_path):
order = _seed_order(
settings.orders_db_path,
order_id="GKO-20260614-DISPATCH-EMAIL",
customer_email="parent@example.com",
)
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
from data.notifications.dispatcher import DeliveryDispatcher
email_sender = _FakeEmailSender()
dispatcher = DeliveryDispatcher.for_db(
settings.orders_db_path,
email_sender=email_sender,
)
try:
result = dispatcher.dispatch_ready_events(channel="email")
finally:
dispatcher.close()
assert result.processed == 1
assert result.delivered == 1
assert result.failed == 0
assert len(email_sender.sent) == 1
assert email_sender.sent[0]["recipient"] == "parent@example.com"
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
event = notification_service.list_events(order.id, channel="email")[0]
finally:
notification_service.close()
assert event.status == "delivered"
payload = json.loads(event.payload_json)
email_notice = payload.get("email_notice")
assert email_notice is not None
assert email_notice["recipient"] == "parent@example.com"
assert email_notice["subject"]
def test_dispatch_ready_station_event_marks_failed_when_pdf_missing(settings, tmp_path):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-DISPATCH-FAIL")
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
with OrdersDAO.connect(settings.orders_db_path) as dao:
current = dao.get(order.id)
assert current.pdf_path is not None
Path(current.pdf_path).unlink()
from data.notifications.dispatcher import DeliveryDispatcher
dispatcher = DeliveryDispatcher.for_db(settings.orders_db_path)
try:
result = dispatcher.dispatch_ready_events(channel="station")
finally:
dispatcher.close()
assert result.processed == 1
assert result.failed == 1
assert result.validated == 0
assert result.delivered == 0
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
failed_event = notification_service.list_events(order.id)[0]
finally:
notification_service.close()
assert failed_event.status == "failed"
assert failed_event.attempt_count == 2
assert failed_event.failure_reason == "delivery artifact missing"
def test_delivery_dispatch_script_prints_summary(settings, tmp_path):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-DISPATCH-CLI")
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
env = {
**__import__("os").environ,
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
}
proc = subprocess.run(
[sys.executable, "scripts/gaokao-delivery-dispatch.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert '"processed": 1' in proc.stdout
assert '"validated": 1' in proc.stdout
assert '"delivered": 0' in proc.stdout
def test_delivery_watchdog_exits_zero_when_no_failures(settings, tmp_path):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-WATCHDOG-OK")
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
env = {
**__import__("os").environ,
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
}
proc = subprocess.run(
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert '"failed": 0' in proc.stdout
def test_delivery_watchdog_exits_nonzero_when_failures_detected(settings, tmp_path):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-WATCHDOG-FAIL")
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id, payload={"candidate_score": 578}, submit=True
)
_attach_ready_delivery(settings, tmp_path, order.id)
with OrdersDAO.connect(settings.orders_db_path) as dao:
current = dao.get(order.id)
assert current.pdf_path is not None
Path(current.pdf_path).unlink()
env = {
**__import__("os").environ,
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
}
proc = subprocess.run(
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 2, proc.stdout + proc.stderr
assert '"failed": 1' in proc.stdout
def test_retention_cleanup_scrubs_notification_payload_for_expired_orders(settings):
from data.orders.retention_cleanup import run_cleanup
order = Order(
id="GKO-20250101-RETENTION-NOTIFY",
source="web",
service_version="standard",
amount_cents=9900,
status="completed",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
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(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed_old_completed")
service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
service.notify_event(
order.id,
event_type="report_ready",
channel="email",
payload_json='{"customer_email":"parent@example.com","detail":"keep"}',
)
finally:
service.close()
result = run_cleanup(
settings.orders_db_path,
cutoff_iso="2025-06-30T00:00:00+00:00",
apply=True,
)
assert result.anonymized == 1
service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
events = service.list_events(order.id)
finally:
service.close()
assert len(events) == 1
assert events[0].payload_json == "{}"
def test_share_access_events_have_retention_cleanup(settings):
from data.orders.retention_cleanup import run_cleanup
from data.share.short_link import ShortLinkService
order = Order(
id="GKO-20250101-RETENTION-SHARE",
source="web",
service_version="standard",
amount_cents=9900,
status="completed",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
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(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed_old_completed")
share = ShortLinkService(db_path=settings.share_db_path)
link = share.create(report_id=order.id)
share.resolve(link.code, visitor_token="visitor-1")
result = run_cleanup(
settings.orders_db_path,
cutoff_iso="2025-06-30T00:00:00+00:00",
apply=True,
share_db_path=settings.share_db_path,
)
assert result.anonymized == 1
with share._connect() as conn:
row = conn.execute(
"SELECT COUNT(*) FROM share_link_access_events WHERE code=?",
(link.code,),
).fetchone()
assert row is not None
assert int(row[0]) == 0