Files

579 lines
17 KiB
Python
Raw Permalink 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.
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.setattr(
decline,
"_persist_detected_events",
lambda events, *_args: len(events),
)
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"),
]
def test_build_decline_notification_message_is_deterministic() -> None:
message = decline.build_decline_notification_message(
[_event()],
end_date="2026-08-01",
window_days=9,
)
assert "销量连续下滑告警(截至 2026-08-013 段 × 3 天)" in message
assert "[tm] 测试款" in message
assert "300 → 210 → 90" in message
assert "累计 -70.0%" in message
assert "ERP" in message
def test_alert_delivery_uuid_is_stable_and_recipient_scoped() -> None:
first = decline._notification_delivery_uuid(
[_event()],
end_date="2026-08-01",
window_days=9,
openid="ou_first",
)
same = decline._notification_delivery_uuid(
[_event()],
end_date="2026-08-01",
window_days=9,
openid="ou_first",
)
second = decline._notification_delivery_uuid(
[_event()],
end_date="2026-08-01",
window_days=9,
openid="ou_second",
)
assert first == same
assert first != second
def test_decline_notification_uses_shared_lark_user_sender(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
def fake_send(**kwargs):
captured.update(kwargs)
return {"message_id": "om_real123"}
monkeypatch.setattr(decline, "send_lark_bot_message", fake_send)
response = decline.send_decline_notification(
[_event()],
"2026-08-01",
9,
openid="ou_owner",
)
assert response["message_id"] == "om_real123"
assert captured["user_id"] == "ou_owner"
assert captured["profile"] == "hermes-analyzer"
assert captured["idempotency_key"] == decline._notification_delivery_uuid(
[_event()],
end_date="2026-08-01",
window_days=9,
openid="ou_owner",
)
assert "测试款" in str(captured["text"])
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, "send_decline_notification", notify)
assert decline.main() == 5
def test_main_does_not_accept_lark_response_without_real_message_id(
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,
"send_decline_notification",
lambda *args, **kwargs: {"ok": True},
)
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,
"send_decline_notification",
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,
"send_decline_notification",
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
def test_main_no_notify_still_detects_hits_and_succeeds(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(
sys,
"argv",
[
"check_nine_day_decline.py",
"--end-date",
"2026-08-01",
"--no-notify",
"--data-root",
str(tmp_path),
],
)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
persisted: list[tuple[list[dict], str, int]] = []
monkeypatch.setattr(
decline,
"_persist_detected_events",
lambda events, end_date, window_days: (
persisted.append((events, end_date, window_days)) or len(events)
),
)
monkeypatch.setattr(
decline,
"_already_notified",
lambda *args: pytest.fail("disabled notification must not query receipts"),
)
monkeypatch.setattr(
decline,
"send_decline_notification",
lambda *args, **kwargs: pytest.fail("disabled notification must not send"),
)
assert decline.main() == 0
assert persisted == [([_event()], "2026-08-01", 9)]
output = capsys.readouterr().out
assert "events=1 notification=disabled persistence=committed" in output
assert "detection_rows=1" in output
def test_main_blocks_notification_when_detection_output_cannot_be_persisted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
monkeypatch.setattr(
decline,
"_persist_detected_events",
lambda *_args: (_ for _ in ()).throw(RuntimeError("write failed")),
)
monkeypatch.setattr(
decline,
"send_decline_notification",
lambda *args, **kwargs: pytest.fail("must not send without durable output"),
)
assert decline.main() == 5
def test_main_sends_and_persists_each_recipient_independently(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_prepare_main(monkeypatch, tmp_path)
monkeypatch.setattr(
sys,
"argv",
[
"check_nine_day_decline.py",
"--end-date",
"2026-08-01",
"--openid",
"ou_first",
"--openid",
"ou_second",
"--data-root",
str(tmp_path),
],
)
monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()])
dedup_queries: list[str] = []
persisted: list[tuple[str, str]] = []
def fake_already_notified(_style, _date, recipient):
dedup_queries.append(recipient)
return False
def fake_notify(*_args, openid, **_kwargs):
return {"message_id": f"om_{openid.removeprefix('ou_')}"}
def fake_persist(*_args, openid, message_id, **_kwargs):
persisted.append((openid, message_id))
return 1
monkeypatch.setattr(decline, "_already_notified", fake_already_notified)
monkeypatch.setattr(decline, "send_decline_notification", fake_notify)
monkeypatch.setattr(decline, "_persist_events", fake_persist)
assert decline.main() == 0
assert dedup_queries == ["ou_first", "ou_second"]
assert persisted == [
("ou_first", "om_first"),
("ou_second", "om_second"),
]
def test_alert_schema_and_upsert_are_recipient_scoped() -> None:
repository_root = Path(__file__).resolve().parents[3]
schema = (
repository_root
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "db"
/ "schema.sql"
).read_text(encoding="utf-8")
db_module = (
repository_root
/ "src"
/ "gyxx_flow"
/ "modules"
/ "product_commerce"
/ "db"
/ "__init__.py"
).read_text(encoding="utf-8")
assert "UNIQUE (style_name, trigger_date, recipient)" in schema
assert "DROP CONSTRAINT IF EXISTS fact_alert_history_style_name_trigger_date_key" in schema
assert "ON CONFLICT (style_name, trigger_date, recipient)" in db_module
assert "def ensure_alert_history_recipient_scope" in db_module
assert "fact_alert_history_style_name_trigger_date_key" in db_module
def test_detected_output_migrates_recipient_scope_before_upsert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_db = ModuleType("db")
connection = object()
calls: list[tuple[str, object]] = []
class ConnectionContext:
def __enter__(self):
return connection
def __exit__(self, *args):
return False
fake_db.get_conn = ConnectionContext # type: ignore[attr-defined]
fake_db.ensure_alert_history_recipient_scope = ( # type: ignore[attr-defined]
lambda conn: calls.append(("migrate", conn))
)
fake_db.upsert_decline_event = ( # type: ignore[attr-defined]
lambda conn, event: calls.append(("upsert", (conn, event)))
)
monkeypatch.setitem(sys.modules, "db", fake_db)
assert decline._persist_detected_events([_event()], "2026-08-01", 9) == 1
assert calls[0] == ("migrate", connection)
assert calls[1][0] == "upsert"
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]