Files
gyxx-flow/tests/modules/product_commerce/test_alert_integrity.py
T

363 lines
10 KiB
Python

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]