"""美团、京东 CPS 每日自动对账 worker。""" from __future__ import annotations import logging from datetime import date, datetime, timedelta import pytest from app.core import cps_reconcile_worker as worker from app.core.config import settings from app.core.rewards import CN_TZ from app.db.session import SessionLocal from app.integrations.meituan import MeituanCpsError def _configure_platforms(monkeypatch) -> None: monkeypatch.setattr(settings, "MT_CPS_APP_KEY", "mt-key") monkeypatch.setattr(settings, "MT_CPS_APP_SECRET", "mt-secret") monkeypatch.setattr(settings, "JD_UNION_APP_KEY", "jd-key") monkeypatch.setattr(settings, "JD_UNION_APP_SECRET", "jd-secret") monkeypatch.setattr(settings, "CPS_AUTO_RECONCILE_LOOKBACK_DAYS", 3) def test_reconcile_once_pulls_meituan_and_jd_by_update_time(monkeypatch) -> None: _configure_platforms(monkeypatch) calls: dict[str, dict] = {} def fake_meituan(db, **kwargs): calls["meituan"] = kwargs return { "fetched": 2, "inserted": 1, "updated": 1, "pages": 1, "api_requests": 1, } def fake_jd(db, **kwargs): calls["jd"] = kwargs return { "fetched": 3, "inserted": 2, "updated": 1, "pages": 2, "api_requests": 72, "windows": 72, } monkeypatch.setattr(worker.cps_repo, "reconcile_orders", fake_meituan) monkeypatch.setattr(worker.cps_repo, "reconcile_jd_orders", fake_jd) monkeypatch.setattr(worker, "_touch_lock", lambda: None) now = datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) result = worker._reconcile_once(now) assert result["errors"] == {} assert result["meituan"]["fetched"] == 2 assert result["jd"]["fetched"] == 3 assert calls["meituan"]["query_time_type"] == 2 assert calls["jd"]["query_time_type"] == 3 assert calls["meituan"]["end_time"] == int(now.timestamp()) assert calls["meituan"]["start_time"] == int((now - timedelta(days=3)).timestamp()) assert calls["jd"]["start_time"] == now - timedelta(days=3) assert calls["jd"]["end_time"] == now assert calls["meituan"]["audit_context"]["run_id"] == result["run_id"] assert calls["jd"]["audit_context"]["run_id"] == result["run_id"] assert result["status"] == "success" assert result["manual_retry_required"] is False def test_meituan_failure_does_not_block_jd(monkeypatch) -> None: _configure_platforms(monkeypatch) jd_called = False def fail_meituan(db, **kwargs): raise MeituanCpsError("temporary failure") def fake_jd(db, **kwargs): nonlocal jd_called jd_called = True return {"fetched": 1, "inserted": 1, "updated": 0, "pages": 1} monkeypatch.setattr(worker.cps_repo, "reconcile_orders", fail_meituan) monkeypatch.setattr(worker.cps_repo, "reconcile_jd_orders", fake_jd) monkeypatch.setattr(worker, "_touch_lock", lambda: None) result = worker._reconcile_once(datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ)) assert result["errors"]["meituan"] == "temporary failure" assert jd_called is True assert result["jd"]["fetched"] == 1 assert result["meituan"]["status"] == "failed" assert result["status"] == "partial_success" assert result["manual_retry_required"] is True def test_should_run_once_after_five_am() -> None: before = datetime(2026, 7, 22, 4, 59, tzinfo=CN_TZ) at_five = datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) assert worker._should_run(None, before, 5) is False assert worker._should_run(None, at_five, 5) is True assert worker._should_run(date(2026, 7, 22), at_five, 5) is False def test_trigger_and_next_run_are_beijing_time() -> None: before_five = datetime(2026, 7, 22, 4, 30, tzinfo=CN_TZ) after_five = datetime(2026, 7, 22, 6, 0, tzinfo=CN_TZ) assert worker._trigger_for_run(before_five, after_five, 5) == "scheduled" assert worker._trigger_for_run(after_five, after_five, 5) == "startup_catchup" assert worker._next_run_at(before_five, 5) == datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) assert worker._next_run_at(after_five, 5) == datetime(2026, 7, 23, 5, 0, tzinfo=CN_TZ) def test_reconcile_emits_structured_audit_logs(monkeypatch, caplog) -> None: _configure_platforms(monkeypatch) monkeypatch.setattr( worker.cps_repo, "reconcile_orders", lambda db, **kwargs: { "fetched": 2, "inserted": 1, "updated": 1, "pages": 1, "api_requests": 1, }, ) monkeypatch.setattr( worker.cps_repo, "reconcile_jd_orders", lambda db, **kwargs: { "fetched": 3, "inserted": 2, "updated": 1, "pages": 1, "api_requests": 72, "windows": 72, }, ) monkeypatch.setattr(worker, "_touch_lock", lambda: None) monkeypatch.setattr( worker, "_cn_now", lambda: datetime(2026, 7, 22, 5, 1, tzinfo=CN_TZ), ) with caplog.at_level(logging.INFO, logger=worker.logger.name): result = worker._reconcile_once( datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ), run_id="audit-run-1", trigger="scheduled", ) records = {record.event: record for record in caplog.records if hasattr(record, "event")} assert records["cps_reconcile.started"].run_id == "audit-run-1" assert records["cps_reconcile.started"].lookback_days == 3 assert records["cps_reconcile.started"].scheduled_date == "2026-07-22" assert records["cps_reconcile.started"].hostname assert records["cps_reconcile.completed"].task_status == "success" assert records["cps_reconcile.completed"].manual_retry_required is False assert records["cps_reconcile.completed"].meituan_result["fetched"] == 2 assert records["cps_reconcile.completed"].jd_result["api_requests"] == 72 assert result["next_run_at"] == "2026-07-23T05:00:00+08:00" def test_jd_request_failure_logs_exact_window(monkeypatch, caplog) -> None: start = datetime(2026, 7, 20, 5, 0, tzinfo=CN_TZ) def fail_query(**kwargs): raise RuntimeError("upstream timeout") monkeypatch.setattr(worker.cps_repo.jd_union, "query_order_rows", fail_query) with SessionLocal() as db, caplog.at_level(logging.ERROR, logger=worker.logger.name): with pytest.raises(RuntimeError, match="upstream timeout"): worker.cps_repo.reconcile_jd_orders( db, start_time=start, end_time=start + timedelta(hours=2), audit_context={"run_id": "audit-run-2", "trigger": "scheduled"}, ) record = next( item for item in caplog.records if getattr(item, "event", "") == "cps_reconcile.request_failed" ) assert record.run_id == "audit-run-2" assert record.platform == "jd" assert record.failed_window_start == "2026-07-20T05:00:00+08:00" assert record.failed_window_end == "2026-07-20T06:00:00+08:00" assert record.failed_page == 1 def test_start_worker_respects_auto_switch(monkeypatch) -> None: monkeypatch.setattr(settings, "CPS_AUTO_RECONCILE_ENABLED", False) assert worker.start_cps_reconcile_worker() is None