from __future__ import annotations import subprocess import sys from datetime import date from pathlib import Path from types import ModuleType import check_nine_day_decline as decline import pytest import run_alerts_with_retry as retry PAIR = ("tm", "测试款") def _segment(sales: int, *, days: int = 3) -> dict: return { "sales": sales, "present_days": days, "erp_style_codes": ["STYLE-1"], } def _event() -> dict: return { "style_name": "测试款", "platform": "tm", "erp_style_codes": ["STYLE-1"], "window_start": "2026-07-24", "window_end": "2026-08-01", "seg1_sales": 300, "seg2_sales": 210, "seg3_sales": 90, "drop_pct_1to2": 30.0, "drop_pct_2to3": 57.1, "drop_pct_1to3": 70.0, } def _prepare_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr( sys, "argv", [ "check_nine_day_decline.py", "--end-date", "2026-08-01", "--openid", "ou_owner", "--data-root", str(tmp_path), ], ) monkeypatch.setattr(decline, "managed_data_path", lambda value: Path(value)) monkeypatch.setattr(decline, "append_log", lambda line: None) monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) def test_detect_decline_uses_three_inclusive_postgres_sum_windows( monkeypatch: pytest.MonkeyPatch, ) -> None: observed: list[list[tuple[date, date]]] = [] def fake_load(windows): observed.append(windows) return [ {PAIR: _segment(300)}, {PAIR: _segment(210)}, {PAIR: _segment(90)}, ] monkeypatch.setattr(decline, "_load_segment_snapshots", fake_load) events = decline.detect_decline("2026-08-01", 9) assert observed == [ [ (date(2026, 7, 24), date(2026, 7, 26)), (date(2026, 7, 27), date(2026, 7, 29)), (date(2026, 7, 30), date(2026, 8, 1)), ] ] assert len(events) == 1 assert events[0]["window_start"] == "2026-07-24" assert events[0]["window_end"] == "2026-08-01" assert [events[0][f"seg{index}_sales"] for index in range(1, 4)] == [ 300, 210, 90, ] assert events[0]["segment_windows"] == [ {"start": "2026-07-24", "end": "2026-07-26", "days": 3}, {"start": "2026-07-27", "end": "2026-07-29", "days": 3}, {"start": "2026-07-30", "end": "2026-08-01", "days": 3}, ] def test_detect_decline_rejects_incomplete_three_day_segment( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( decline, "_load_segment_snapshots", lambda windows: [ {PAIR: _segment(300)}, {PAIR: _segment(210, days=2)}, {PAIR: _segment(90)}, ], ) assert decline.detect_decline("2026-08-01", 9) == [] def test_segment_loader_uses_postgres_range_sum_on_one_connection( monkeypatch: pytest.MonkeyPatch, ) -> None: fake_db = ModuleType("db") connection = object() observed: list[tuple[object, str, str]] = [] class ConnectionContext: def __enter__(self): return connection def __exit__(self, *args): return False def fake_fetch(conn, start, end): observed.append((conn, start, end)) return {} fake_db.get_conn = ConnectionContext # type: ignore[attr-defined] fake_db.fetch_daily_for_range = fake_fetch # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "db", fake_db) windows = decline._segment_windows("2026-08-01", 9) assert decline._load_segment_snapshots(windows) == [{}, {}, {}] assert observed == [ (connection, "2026-07-24", "2026-07-26"), (connection, "2026-07-27", "2026-07-29"), (connection, "2026-07-30", "2026-08-01"), ] @pytest.mark.parametrize( ("response", "expected"), [ ({"id": "chatcmpl-not-a-receipt"}, None), ({"message_id": "om_direct123"}, "om_direct123"), ({"data": {"message_id": "om_nested123"}}, "om_nested123"), ( {"choices": [{"message": {"content": "飞书回执 om_content123"}}]}, "om_content123", ), ( {"choices": [{"message": {"content": "发送成功,但没有回执"}}]}, None, ), ], ) def test_extract_feishu_message_id_requires_real_receipt_shape( response: dict, expected: str | None, ) -> None: assert decline.extract_feishu_message_id(response) == expected def test_main_fails_when_postgres_detection_read_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: _prepare_main(monkeypatch, tmp_path) monkeypatch.setattr( decline, "detect_decline", lambda *args: (_ for _ in ()).throw(RuntimeError("postgres unavailable")), ) assert decline.main() == 1 def test_main_zero_events_has_explicit_postcondition( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: _prepare_main(monkeypatch, tmp_path) monkeypatch.setattr(decline, "detect_decline", lambda *args: []) assert decline.main() == 0 output = capsys.readouterr().out assert "detection_source=postgresql events=0" in output assert "notification=not_required persistence=not_required" in output def test_main_dedup_query_failure_is_fail_closed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: _prepare_main(monkeypatch, tmp_path) monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) monkeypatch.setattr( decline, "_already_notified", lambda *args: (_ for _ in ()).throw(RuntimeError("db timeout")), ) notify = pytest.fail monkeypatch.setattr(decline, "notify_hermes", notify) assert decline.main() == 5 def test_main_does_not_accept_arbitrary_non_error_hermes_text( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: _prepare_main(monkeypatch, tmp_path) monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, "notify_hermes", lambda *args, **kwargs: { "id": "chatcmpl-123", "choices": [{"message": {"content": "发送成功"}}], }, ) persist = pytest.fail monkeypatch.setattr(decline, "_persist_events", persist) assert decline.main() == 6 def test_main_returns_nonzero_when_receipt_persistence_fails( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: _prepare_main(monkeypatch, tmp_path) monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, "notify_hermes", lambda *args, **kwargs: {"message_id": "om_real123"}, ) monkeypatch.setattr( decline, "_persist_events", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("write failed")), ) assert decline.main() == 4 def test_main_success_requires_and_persists_feishu_receipt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: _prepare_main(monkeypatch, tmp_path) observed: dict[str, object] = {} monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, "notify_hermes", lambda *args, **kwargs: {"message_id": "om_real123"}, ) def fake_persist(*args, **kwargs): observed.update(kwargs) return 1 monkeypatch.setattr(decline, "_persist_events", fake_persist) assert decline.main() == 0 assert observed["message_id"] == "om_real123" assert observed["openid"] == "ou_owner" output = capsys.readouterr().out assert "notification=sent feishu_message_id=om_real123" in output assert "persistence=committed history_rows=1" in output class _FailedCollector: def __init__(self, *args): self.pages: list[tuple] = [] def add_page(self, *args, **kwargs): self.pages.append((args, kwargs)) def write(self): return None def summary_line(self): return "failure" def test_retry_wrapper_does_not_repeat_after_possible_success( monkeypatch: pytest.MonkeyPatch, ) -> None: calls: list[list[str]] = [] monkeypatch.setattr( sys, "argv", [ "run_alerts_with_retry.py", "--end-date", "2026-08-01", "--openid", "ou_owner", ], ) monkeypatch.setattr(retry, "FailedCollector", _FailedCollector) monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None) monkeypatch.setattr(retry, "build_child_environment", lambda *args: {}) monkeypatch.setattr( retry.time, "sleep", lambda seconds: pytest.fail("non-retryable result must not sleep"), ) def fake_run(command, **kwargs): calls.append(command) return subprocess.CompletedProcess(command, 4) monkeypatch.setattr(retry.subprocess, "run", fake_run) assert retry.main() == 4 assert len(calls) == 1 def test_retry_wrapper_can_retry_fail_closed_dedup_read( monkeypatch: pytest.MonkeyPatch, ) -> None: return_codes = iter((5, 0)) sleeps: list[int] = [] monkeypatch.setattr( sys, "argv", [ "run_alerts_with_retry.py", "--end-date", "2026-08-01", "--openid", "ou_owner", ], ) monkeypatch.setattr(retry, "FailedCollector", _FailedCollector) monkeypatch.setattr(retry, "consolidate_failed_logs", lambda value: None) monkeypatch.setattr(retry, "build_child_environment", lambda *args: {}) monkeypatch.setattr(retry.time, "sleep", sleeps.append) monkeypatch.setattr( retry.subprocess, "run", lambda command, **kwargs: subprocess.CompletedProcess( command, next(return_codes), ), ) assert retry.main() == 0 assert sleeps == [retry.RUN_SLEEP_SECONDS]