feat: expand module workflows, dynamic config, notifications and console API

This commit is contained in:
2026-09-04 09:49:37 +08:00
parent 8df5266abb
commit b124d757b0
309 changed files with 89358 additions and 6232 deletions
@@ -53,6 +53,11 @@ def _prepare_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
)
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)
@@ -142,27 +147,73 @@ def test_segment_loader_uses_postgres_range_sum_on_one_connection(
]
@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,
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-01,3 段 × 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:
assert decline.extract_feishu_message_id(response) == expected
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(
@@ -205,12 +256,12 @@ def test_main_dedup_query_failure_is_fail_closed(
lambda *args: (_ for _ in ()).throw(RuntimeError("db timeout")),
)
notify = pytest.fail
monkeypatch.setattr(decline, "notify_hermes", notify)
monkeypatch.setattr(decline, "send_decline_notification", notify)
assert decline.main() == 5
def test_main_does_not_accept_arbitrary_non_error_hermes_text(
def test_main_does_not_accept_lark_response_without_real_message_id(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -219,11 +270,8 @@ def test_main_does_not_accept_arbitrary_non_error_hermes_text(
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
lambda *args, **kwargs: {
"id": "chatcmpl-123",
"choices": [{"message": {"content": "发送成功"}}],
},
"send_decline_notification",
lambda *args, **kwargs: {"ok": True},
)
persist = pytest.fail
monkeypatch.setattr(decline, "_persist_events", persist)
@@ -240,7 +288,7 @@ def test_main_returns_nonzero_when_receipt_persistence_fails(
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
"send_decline_notification",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
monkeypatch.setattr(
@@ -263,7 +311,7 @@ def test_main_success_requires_and_persists_feishu_receipt(
monkeypatch.setattr(decline, "_already_notified", lambda *args: False)
monkeypatch.setattr(
decline,
"notify_hermes",
"send_decline_notification",
lambda *args, **kwargs: {"message_id": "om_real123"},
)
@@ -281,6 +329,174 @@ def test_main_success_requires_and_persists_feishu_receipt(
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] = []