2090 lines
65 KiB
Python
2090 lines
65 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from gyxx_flow.adapters import (
|
|
COOKIE_SKIP_EXIT_CODE,
|
|
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
|
)
|
|
from gyxx_flow.modules.supply_chain import run as supply_run
|
|
from gyxx_flow.modules.supply_chain.orchestrator import config as supply_config
|
|
from gyxx_flow.modules.supply_chain.orchestrator import (
|
|
mcp_workflow,
|
|
pg_writer,
|
|
runner,
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import (
|
|
send_card_notification,
|
|
)
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SUPPLY_WRAPPERS = (
|
|
PROJECT_ROOT
|
|
/ "src"
|
|
/ "gyxx_flow"
|
|
/ "modules"
|
|
/ "supply_chain"
|
|
/ "orchestrator"
|
|
/ "scripts"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def acceptance_environment(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> Path:
|
|
evidence = tmp_path / "evidence.jsonl"
|
|
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
|
|
monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence))
|
|
monkeypatch.setenv("GYXX_DATA_ROOT", str(tmp_path / "data"))
|
|
return evidence
|
|
|
|
|
|
def _forbidden(message: str):
|
|
def fail(*_args, **_kwargs):
|
|
pytest.fail(message)
|
|
|
|
return fail
|
|
|
|
|
|
def test_frontend_supply_run_uses_stable_business_day_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
|
|
|
assert mcp_workflow._resolve_workflow_id("purchase-confirmation", None) == (
|
|
"purchase-confirmation_20260803"
|
|
)
|
|
assert mcp_workflow._resolve_workflow_id(
|
|
"purchase-confirmation", "explicit-run"
|
|
) == "explicit-run"
|
|
|
|
|
|
def test_invalid_business_date_keeps_unique_run_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/08/03")
|
|
|
|
first = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
|
|
second = mcp_workflow._resolve_workflow_id("purchase-confirmation", None)
|
|
|
|
assert first.startswith("purchase-confirmation_")
|
|
assert first != second
|
|
|
|
|
|
def test_local_hermes_profile_api_key_is_used_without_copying_secret(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
profile = tmp_path / "profiles" / "data-analyzer"
|
|
profile.mkdir(parents=True)
|
|
(profile / ".env").write_text(
|
|
"API_SERVER_PORT=8642\nAPI_SERVER_KEY=local-only-key\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.delenv("GYXX_SUPPLY_HERMES_TOKEN", raising=False)
|
|
monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False)
|
|
monkeypatch.delenv("HERMES_API_KEY", raising=False)
|
|
|
|
assert supply_config._hermes_profile_api_key("data-analyzer") == (
|
|
"local-only-key"
|
|
)
|
|
|
|
|
|
def test_stable_run_manifest_accepts_only_current_attempt_files(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
run_dir = tmp_path / "purchase-confirmation" / "run_id=stable-run"
|
|
run_dir.mkdir(parents=True)
|
|
old_file = run_dir / "old.csv"
|
|
current_file = run_dir / "current.csv"
|
|
old_file.write_text("old", encoding="utf-8")
|
|
current_file.write_text("current", encoding="utf-8")
|
|
(run_dir / "_run_manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"run_id": "stable-run",
|
|
"files": [old_file.name, current_file.name],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "SHARED_DIR", tmp_path)
|
|
|
|
assert mcp_workflow._validate_run_manifest(
|
|
"purchase-confirmation", "stable-run", [str(current_file)]
|
|
) == (True, "")
|
|
assert set(
|
|
mcp_workflow._reusable_collected_files(
|
|
"purchase-confirmation", "stable-run"
|
|
)
|
|
) == {str(old_file), str(current_file)}
|
|
|
|
|
|
def test_replenishment_batch_uses_stable_business_day_ids(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
|
|
|
|
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
|
|
|
assert batch_process._stable_run_id("replenishment") == (
|
|
"replenishment_20260803"
|
|
)
|
|
assert batch_process._stable_run_id("replenishment-alert") == (
|
|
"replenishment-alert_20260803"
|
|
)
|
|
|
|
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
|
|
assert batch_process._is_alert_only_target()
|
|
|
|
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment")
|
|
assert not batch_process._is_alert_only_target()
|
|
|
|
|
|
def test_alert_batch_stops_before_replenishment_side_effects(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import batch_process
|
|
|
|
threshold_file = tmp_path / "threshold_alert_data.json"
|
|
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-03")
|
|
monkeypatch.setenv("AUTOFLOW_TARGET_WORKFLOW", "replenishment-alert")
|
|
monkeypatch.setattr(batch_process, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr(batch_process, "THRESHOLD_ALERT_FILE", threshold_file)
|
|
monkeypatch.setattr(batch_process, "_PG_IMPORT_OK", False)
|
|
monkeypatch.setattr(batch_process, "_run_product_replenishment", lambda: None)
|
|
monkeypatch.setattr(
|
|
batch_process,
|
|
"_ensure_threshold_alert_file",
|
|
lambda: {"alert_count": 1, "alerts": [{"sku": "sku-1"}]},
|
|
)
|
|
monkeypatch.setattr(
|
|
batch_process,
|
|
"_pick_latest_xlsx",
|
|
_forbidden("alert workflow must not enter replenishment persistence"),
|
|
)
|
|
|
|
assert batch_process.main() == 0
|
|
payload = json.loads(threshold_file.read_text(encoding="utf-8"))
|
|
assert payload["run_id"] == "replenishment-alert_20260803"
|
|
assert payload["pg_ok"] is False
|
|
|
|
|
|
def _forbid_gateway_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acquire_workflow_lock",
|
|
_forbidden("cookie preflight must run before workflow locking"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_collector_heartbeat",
|
|
_forbidden("cookie preflight must run before Feishu heartbeats"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"call_hermes",
|
|
_forbidden("cookie preflight must run before Hermes"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_run_local_collection_script",
|
|
_forbidden("cookie preflight must not start a browser collector"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow.requests,
|
|
"Session",
|
|
_forbidden("cookie preflight must not create an HTTP session"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow.requests,
|
|
"post",
|
|
_forbidden("cookie preflight must not send an HTTP request"),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("workflow_name", "expected_script_id"),
|
|
[
|
|
(
|
|
"purchase-confirmation",
|
|
"supply_chain:orchestrator/scripts/PurchaseConfirmation.py",
|
|
),
|
|
(
|
|
"replenishment-alert",
|
|
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
|
|
),
|
|
(
|
|
"replenishment",
|
|
"supply_chain:orchestrator/scripts/ProductReplenishment.py",
|
|
),
|
|
],
|
|
)
|
|
def test_gateway_skips_missing_cookie_before_all_external_effects(
|
|
workflow_name: str,
|
|
expected_script_id: str,
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
|
_forbid_gateway_side_effects(monkeypatch)
|
|
|
|
result = mcp_workflow.run_mcp_workflow(workflow_name, "acceptance-run")
|
|
|
|
assert result["status"] == "skipped_cookie"
|
|
assert result["workflow_id"] == "acceptance-run"
|
|
assert result["cookie_preflight"]["script_id"] == expected_script_id
|
|
assert result["cookie_preflight"]["status"] == "SKIPPED_COOKIE"
|
|
assert result["reason"] == "browser state is missing"
|
|
|
|
events = [
|
|
json.loads(line)
|
|
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
|
|
]
|
|
gateway_event = next(
|
|
item for item in events if item["event"] == "supply_gateway_cookie_skipped"
|
|
)
|
|
assert gateway_event["operation"] == f"supply.{workflow_name}.cookie_preflight"
|
|
assert gateway_event["details"]["script_id"] == expected_script_id
|
|
assert gateway_event["details"]["workflow_id"] == "acceptance-run"
|
|
|
|
|
|
def test_profile_without_required_platform_cookie_is_still_skipped(
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
|
binding = mcp_workflow._acceptance_binding_for_workflow("purchase-confirmation")
|
|
cookie_db = binding.profile_dir / "Default" / "Network" / "Cookies"
|
|
cookie_db.parent.mkdir(parents=True)
|
|
with sqlite3.connect(cookie_db) as connection:
|
|
connection.execute("CREATE TABLE cookies (expires_utc INTEGER)")
|
|
connection.execute(
|
|
"INSERT INTO cookies (expires_utc) VALUES (?)",
|
|
(999_999_999_999_999_999,),
|
|
)
|
|
(binding.profile_dir / "Local State").write_text("{}", encoding="utf-8")
|
|
_forbid_gateway_side_effects(monkeypatch)
|
|
|
|
result = mcp_workflow.run_mcp_workflow(
|
|
"purchase-confirmation",
|
|
"nonempty-profile-run",
|
|
)
|
|
|
|
assert result["status"] == "skipped_cookie"
|
|
assert result["cookie_preflight"]["script_id"].endswith(
|
|
"/PurchaseConfirmation.py"
|
|
)
|
|
assert result["reason"] == "browser state is missing"
|
|
events = [
|
|
json.loads(line)
|
|
for line in acceptance_environment.read_text(encoding="utf-8").splitlines()
|
|
]
|
|
assert events[-1]["event"] == "supply_gateway_cookie_skipped"
|
|
assert events[-1]["details"]["reason"] == "browser state is missing"
|
|
|
|
|
|
def test_supply_credentials_allow_preflight_without_cookie_or_live_cdp(
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
del acceptance_environment
|
|
monkeypatch.setenv("GYXX_SUPPLY_ERP_PASSWORD", "configured-for-test")
|
|
lock_calls: list[tuple[str, str]] = []
|
|
|
|
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
|
|
lock_calls.append((workflow_name, workflow_id))
|
|
return False, "stop-after-preflight"
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
|
|
|
|
result = mcp_workflow.run_mcp_workflow(
|
|
"purchase-confirmation",
|
|
"credential-fallback-run",
|
|
)
|
|
|
|
assert result["status"] == "failed"
|
|
assert result["error"] == "stop-after-preflight"
|
|
assert lock_calls == [("purchase-confirmation", "credential-fallback-run")]
|
|
|
|
|
|
def test_analyzer_facts_include_authoritative_absolute_collected_files(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
collected_file = tmp_path / "purchase_confirmation.csv"
|
|
|
|
facts = mcp_workflow._build_analyze_facts(
|
|
"purchase-confirmation",
|
|
[str(collected_file)],
|
|
)
|
|
|
|
assert facts == [
|
|
"AUTHORITATIVE COLLECTED FILES (absolute paths): "
|
|
f"{collected_file.resolve()}"
|
|
]
|
|
|
|
|
|
def test_close_workflow_browser_terminates_only_bound_cdp_tree(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class FakeProcess:
|
|
def __init__(
|
|
self,
|
|
pid: int,
|
|
cmdline: list[str],
|
|
children: list[FakeProcess] | None = None,
|
|
) -> None:
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "cmdline": cmdline}
|
|
self._children = children or []
|
|
self.terminated = False
|
|
self.killed = False
|
|
|
|
def children(self, *, recursive: bool) -> list[FakeProcess]:
|
|
assert recursive is True
|
|
return self._children
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
|
|
def kill(self) -> None:
|
|
self.killed = True
|
|
|
|
child = FakeProcess(102, ["chrome.exe", "--type=renderer"])
|
|
bound = FakeProcess(
|
|
101,
|
|
["chrome.exe", "--remote-debugging-port=22116"],
|
|
[child],
|
|
)
|
|
unrelated = FakeProcess(
|
|
201,
|
|
["chrome.exe", "--remote-debugging-port=22115"],
|
|
)
|
|
waited: list[tuple[int, ...]] = []
|
|
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acceptance_binding_for_workflow",
|
|
lambda _workflow_name: SimpleNamespace(cdp_port=22116),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow.psutil,
|
|
"process_iter",
|
|
lambda _attrs: [bound, unrelated],
|
|
)
|
|
|
|
def fake_wait_procs(processes, *, timeout: int):
|
|
assert timeout == 5
|
|
waited.append(tuple(process.pid for process in processes))
|
|
return list(processes), []
|
|
|
|
monkeypatch.setattr(mcp_workflow.psutil, "wait_procs", fake_wait_procs)
|
|
|
|
closed = mcp_workflow._close_workflow_browser("purchase-confirmation")
|
|
|
|
assert closed == [101, 102]
|
|
assert bound.terminated is True
|
|
assert child.terminated is True
|
|
assert unrelated.terminated is False
|
|
assert unrelated.killed is False
|
|
assert waited == [(102, 101)]
|
|
|
|
|
|
def test_close_workflow_browser_ignores_other_workflows(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acceptance_binding_for_workflow",
|
|
_forbidden("unmanaged workflow must not resolve a browser binding"),
|
|
)
|
|
|
|
assert mcp_workflow._close_workflow_browser("purchase-order-update") == []
|
|
|
|
|
|
def test_purchase_order_update_block_precedes_cookie_preflight(
|
|
acceptance_environment: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acceptance_cookie_preflight",
|
|
_forbidden("ERP mutation block must take precedence over cookie inspection"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acquire_workflow_lock",
|
|
_forbidden("blocked ERP workflow must not acquire a lock"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_collector_heartbeat",
|
|
_forbidden("blocked ERP workflow must not send a heartbeat"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_purchase_order_update_notification",
|
|
_forbidden("blocked ERP workflow must not notify a user or group"),
|
|
)
|
|
|
|
result = mcp_workflow.run_mcp_workflow(
|
|
"purchase-order-update",
|
|
"blocked-erp-run",
|
|
)
|
|
|
|
assert result["status"] == "blocked_business_mutation"
|
|
assert "business_mutation_blocked" in acceptance_environment.read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
def test_analyzer_prompt_does_not_rely_on_two_hop_acceptance_environment(
|
|
acceptance_environment: Path,
|
|
) -> None:
|
|
prompt = mcp_workflow._build_analyze_prompt("replenishment")
|
|
|
|
assert "Hermes 网关不会继承编排进程的验收环境变量" in prompt
|
|
assert "不得调用 insert_replenishment_bitable.py" in prompt
|
|
assert prompt.rfind("【验收最终覆盖规则】") > prompt.rfind(
|
|
"多维表写入统一走 Python 脚本"
|
|
)
|
|
assert mcp_workflow.ANALYZER_OWNER_OPEN_ID in prompt
|
|
assert "ou_8ee224968aa26a74c7d30ba27fed5eeb" not in prompt
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_text",
|
|
[
|
|
"没有向业务用户发送任何过程或进度消息,仅投递最终业务通知。",
|
|
"未向目标用户推送额外进度更新,只发送最终结果。",
|
|
(
|
|
"No additional process/progress messages were sent to users; "
|
|
"final business notification delivered."
|
|
),
|
|
],
|
|
)
|
|
def test_notification_policy_allows_explicitly_negated_process_messages(
|
|
response_text: str,
|
|
) -> None:
|
|
assert not mcp_workflow._contains_process_notification_violation(response_text)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_text",
|
|
[
|
|
"已向业务用户发送进度通知。",
|
|
"Progress notification sent to the target user.",
|
|
],
|
|
)
|
|
def test_notification_policy_rejects_process_messages_sent_to_users(
|
|
response_text: str,
|
|
) -> None:
|
|
assert mcp_workflow._contains_process_notification_violation(response_text)
|
|
|
|
|
|
def test_notification_receipt_rejects_empty_message_ids() -> None:
|
|
with pytest.raises(ValueError, match="non-empty message_id"):
|
|
pg_writer.mark_notification_sent("replenishment", "run-1", [])
|
|
|
|
|
|
def test_notification_disabled_callback_accepts_an_explicit_empty_receipt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
statements: list[tuple[str, object]] = []
|
|
|
|
class FakeConnection:
|
|
def cursor(self):
|
|
return self
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def execute(self, sql, params):
|
|
statements.append((sql, params))
|
|
|
|
def commit(self):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
|
|
|
pg_writer.mark_notification_sent(
|
|
"replenishment",
|
|
"run-disabled",
|
|
[],
|
|
notification_disabled=True,
|
|
expected_recipients=[],
|
|
)
|
|
|
|
assert len(statements) == 1
|
|
sql, params = statements[0]
|
|
assert "notification_disabled" in sql
|
|
assert "notification_disabled_at" in sql
|
|
assert "notification_message_ids" not in sql
|
|
assert params == ("replenishment", "run-disabled")
|
|
|
|
|
|
def test_notification_receipt_requires_complete_explicit_recipient_mapping(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
statements: list[tuple[str, object]] = []
|
|
|
|
class FakeConnection:
|
|
def cursor(self):
|
|
return self
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def execute(self, sql, params):
|
|
statements.append((sql, params))
|
|
|
|
def commit(self):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
|
recipients = ["ou_first", "ou_second", "ou_first"]
|
|
|
|
with pytest.raises(ValueError, match="recipient_message_ids"):
|
|
pg_writer.mark_notification_sent(
|
|
"replenishment",
|
|
"run-partial",
|
|
message_ids=["mid-first"],
|
|
expected_recipients=recipients,
|
|
recipient_message_ids={"ou_first": "mid-first"},
|
|
)
|
|
|
|
assert statements == []
|
|
|
|
pg_writer.mark_notification_sent(
|
|
"replenishment",
|
|
"run-complete",
|
|
expected_recipients=recipients,
|
|
recipient_message_ids={
|
|
"ou_first": "mid-first",
|
|
"ou_second": "mid-second",
|
|
},
|
|
)
|
|
|
|
assert len(statements) == 3
|
|
run_statement = next(
|
|
(sql, params)
|
|
for sql, params in statements
|
|
if "UPDATE workflow_runs" in sql
|
|
)
|
|
assert "notification_recipient_message_ids" in run_statement[0]
|
|
assert json.loads(run_statement[1][1]) == {
|
|
"ou_first": "mid-first",
|
|
"ou_second": "mid-second",
|
|
}
|
|
assert "'notification_disabled', false" in run_statement[0]
|
|
|
|
|
|
def test_reenabled_notification_writes_receipts_without_disabled_overwrite(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
statements: list[tuple[str, object]] = []
|
|
|
|
class FakeConnection:
|
|
def cursor(self):
|
|
return self
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def execute(self, sql, params):
|
|
statements.append((sql, params))
|
|
|
|
def commit(self):
|
|
return None
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
|
|
|
pg_writer.mark_notification_sent(
|
|
"purchase-confirmation",
|
|
"same-run",
|
|
notification_disabled=True,
|
|
expected_recipients=[],
|
|
)
|
|
pg_writer.mark_notification_sent(
|
|
"purchase-confirmation",
|
|
"same-run",
|
|
expected_recipients=["ou_owner"],
|
|
recipient_message_ids={"ou_owner": "mid-owner"},
|
|
)
|
|
|
|
disabled_sql = statements[0][0]
|
|
assert "notification_message_ids" not in disabled_sql
|
|
enabled_run_sql = next(
|
|
sql
|
|
for sql, _params in statements[1:]
|
|
if "UPDATE workflow_runs" in sql
|
|
)
|
|
assert "notification_recipient_message_ids" in enabled_run_sql
|
|
assert "'notification_disabled', false" in enabled_run_sql
|
|
|
|
|
|
def test_supply_route_resolution_does_not_recurse_and_keeps_all_recipients(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[tuple[str, tuple[str, ...]]] = []
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
|
|
def fake_resolve(workflow_id, defaults):
|
|
calls.append((workflow_id, tuple(defaults)))
|
|
return SimpleNamespace(enabled=True, open_ids=("ou_first", "ou_second"))
|
|
|
|
monkeypatch.setattr(mcp_workflow, "resolve_notification_route", fake_resolve)
|
|
|
|
enabled, recipients = mcp_workflow._resolve_business_notification_route(
|
|
"purchase-confirmation"
|
|
)
|
|
|
|
assert enabled is True
|
|
assert recipients == ("ou_first", "ou_second")
|
|
assert calls == [
|
|
(
|
|
"supply.purchase_confirmation.daily",
|
|
mcp_workflow.PURCHASE_CONFIRMATION_TARGETS,
|
|
)
|
|
]
|
|
|
|
|
|
def test_supply_sender_hard_rejects_disabled_or_drifted_route_snapshot(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
|
|
disabled_run = "disabled-run"
|
|
disabled_path = send_card_notification._notification_snapshot_path(
|
|
"replenishment",
|
|
disabled_run,
|
|
)
|
|
disabled_path.parent.mkdir(parents=True)
|
|
disabled_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": "replenishment",
|
|
"run_id": disabled_run,
|
|
"enabled": False,
|
|
"recipients": [],
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="route is disabled"):
|
|
send_card_notification._resolved_receivers(
|
|
"replenishment",
|
|
disabled_run,
|
|
["ou_launch123"],
|
|
)
|
|
|
|
enabled_run = "enabled-run"
|
|
enabled_path = send_card_notification._notification_snapshot_path(
|
|
"replenishment",
|
|
enabled_run,
|
|
)
|
|
enabled_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": "replenishment",
|
|
"run_id": enabled_run,
|
|
"enabled": True,
|
|
"recipients": ["ou_launch123"],
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(RuntimeError, match="snapshot mismatch"):
|
|
send_card_notification._resolved_receivers(
|
|
"replenishment",
|
|
enabled_run,
|
|
["ou_arbitrary123"],
|
|
)
|
|
|
|
|
|
def test_supply_sender_keeps_exact_launch_snapshot_and_partial_delivery_mapping(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
run_id = "run-1"
|
|
launch_recipients = ["ou_first123", "ou_middle123", "ou_last1234"]
|
|
snapshot_path = send_card_notification._notification_snapshot_path(
|
|
"replenishment",
|
|
run_id,
|
|
)
|
|
snapshot_path.parent.mkdir(parents=True)
|
|
snapshot_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": "replenishment",
|
|
"run_id": run_id,
|
|
"enabled": True,
|
|
"recipients": launch_recipients,
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
def fake_send(open_id, _card, _key):
|
|
if open_id == "ou_middle123":
|
|
raise RuntimeError("middle failed")
|
|
return f"om_{open_id.removeprefix('ou_')}"
|
|
|
|
monkeypatch.setattr(send_card_notification, "_send_card", fake_send)
|
|
monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True)
|
|
|
|
result = send_card_notification.run(
|
|
launch_recipients,
|
|
{"elements": []},
|
|
workflow="replenishment",
|
|
run_id=run_id,
|
|
idempotency_key="replenishment:run-1",
|
|
)
|
|
|
|
assert result == {
|
|
"message_ids": ["om_first123", "om_last1234"],
|
|
"recipient_message_ids": {
|
|
"ou_first123": "om_first123",
|
|
"ou_last1234": "om_last1234",
|
|
},
|
|
"errors": [{"open_id": "ou_middle123", "error": "middle failed"}],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"workflow",
|
|
("purchase-confirmation", "replenishment-alert", "replenishment"),
|
|
)
|
|
def test_supply_sender_persists_and_verifies_complete_receipts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
workflow: str,
|
|
) -> None:
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
run_id = "run-complete"
|
|
recipients = ["ou_first123", "ou_second123"]
|
|
snapshot_path = send_card_notification._notification_snapshot_path(
|
|
workflow,
|
|
run_id,
|
|
)
|
|
snapshot_path.parent.mkdir(parents=True)
|
|
snapshot_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": workflow,
|
|
"run_id": run_id,
|
|
"enabled": True,
|
|
"recipients": recipients,
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
send_card_notification,
|
|
"_send_card",
|
|
lambda open_id, _card, _key: f"om_{open_id.removeprefix('ou_')}",
|
|
)
|
|
monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True)
|
|
persisted: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
|
monkeypatch.setattr(
|
|
send_card_notification.pg_writer,
|
|
"mark_notification_sent",
|
|
lambda *args, **kwargs: persisted.append((args, kwargs)),
|
|
)
|
|
monkeypatch.setattr(
|
|
send_card_notification.pg_writer,
|
|
"notification_already_sent",
|
|
lambda checked_workflow, checked_run_id, *, expected_recipients: (
|
|
checked_workflow == workflow
|
|
and checked_run_id == run_id
|
|
and tuple(expected_recipients) == tuple(recipients)
|
|
),
|
|
)
|
|
|
|
result = send_card_notification.run(
|
|
recipients,
|
|
{"elements": []},
|
|
workflow=workflow,
|
|
run_id=run_id,
|
|
idempotency_key=f"{workflow}:{run_id}",
|
|
)
|
|
|
|
assert result["errors"] == []
|
|
assert persisted == [
|
|
(
|
|
(workflow, run_id),
|
|
{
|
|
"expected_recipients": recipients,
|
|
"recipient_message_ids": {
|
|
"ou_first123": "om_first123",
|
|
"ou_second123": "om_second123",
|
|
},
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_supply_sender_fails_closed_when_complete_receipt_is_not_readable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
workflow = "replenishment-alert"
|
|
run_id = "run-unverified"
|
|
recipients = ["ou_owner123"]
|
|
snapshot_path = send_card_notification._notification_snapshot_path(
|
|
workflow,
|
|
run_id,
|
|
)
|
|
snapshot_path.parent.mkdir(parents=True)
|
|
snapshot_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": workflow,
|
|
"run_id": run_id,
|
|
"enabled": True,
|
|
"recipients": recipients,
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(
|
|
send_card_notification,
|
|
"_send_card",
|
|
lambda _open_id, _card, _key: "om_owner123",
|
|
)
|
|
monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True)
|
|
monkeypatch.setattr(
|
|
send_card_notification.pg_writer,
|
|
"mark_notification_sent",
|
|
lambda *_args, **_kwargs: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
send_card_notification.pg_writer,
|
|
"notification_already_sent",
|
|
lambda *_args, **_kwargs: False,
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="could not be verified"):
|
|
send_card_notification.run(
|
|
recipients,
|
|
{"elements": []},
|
|
workflow=workflow,
|
|
run_id=run_id,
|
|
idempotency_key=f"{workflow}:{run_id}",
|
|
)
|
|
|
|
|
|
def test_supply_sender_refuses_send_when_receipt_store_is_unavailable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
workflow = "purchase-confirmation"
|
|
run_id = "run-no-pg"
|
|
recipients = ["ou_owner123"]
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
snapshot_path = send_card_notification._notification_snapshot_path(workflow, run_id)
|
|
snapshot_path.parent.mkdir(parents=True)
|
|
snapshot_path.write_text(
|
|
json.dumps({"schema_version": 1, "workflow": workflow, "run_id": run_id, "enabled": True, "recipients": recipients, "acceptance": False}),
|
|
encoding="utf-8",
|
|
)
|
|
sent: list[str] = []
|
|
monkeypatch.setattr(send_card_notification, "_send_card", lambda *_args: sent.append("sent"))
|
|
monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: False)
|
|
|
|
with pytest.raises(RuntimeError, match="receipt store is unavailable"):
|
|
send_card_notification.run(recipients, {"elements": []}, workflow=workflow, run_id=run_id)
|
|
|
|
assert sent == []
|
|
|
|
|
|
def test_supply_acceptance_snapshot_survives_missing_wrapper_environment(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
acceptance_recipient = supply_config.FEISHU_CONFIG["apps"]["analyzer"][
|
|
"owner_open_id"
|
|
]
|
|
monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1")
|
|
monkeypatch.setenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", "1")
|
|
monkeypatch.setenv("GYXX_COOKIE_INVALID_SKIP", "1")
|
|
monkeypatch.setenv(
|
|
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
|
|
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path)
|
|
monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_resolve_business_notification_route",
|
|
lambda _workflow: (True, (acceptance_recipient,)),
|
|
)
|
|
run_id = "acceptance-run"
|
|
snapshot = mcp_workflow._load_or_create_notification_snapshot(
|
|
"replenishment",
|
|
run_id,
|
|
)
|
|
assert snapshot == (True, (acceptance_recipient,), True)
|
|
prompt = mcp_workflow._build_analyze_prompt(
|
|
"replenishment",
|
|
notification_route=(snapshot[0], snapshot[1]),
|
|
notification_acceptance=snapshot[2],
|
|
run_id=run_id,
|
|
)
|
|
assert "Hermes must not send any Feishu message" in prompt
|
|
assert "--acceptance-override" not in prompt
|
|
assert acceptance_recipient in prompt
|
|
|
|
# The analyzer wrapper is a separate process and does not inherit the
|
|
# orchestrator's acceptance environment. The external run snapshot remains the
|
|
# sole trust source even if the mutable page route changes after launch.
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_resolve_business_notification_route",
|
|
lambda _workflow: (True, ("ou_newroute123",)),
|
|
)
|
|
|
|
assert mcp_workflow._load_or_create_notification_snapshot(
|
|
"replenishment",
|
|
run_id,
|
|
) == snapshot
|
|
assert send_card_notification._resolved_receivers(
|
|
"replenishment",
|
|
run_id,
|
|
[acceptance_recipient],
|
|
) == [acceptance_recipient]
|
|
with pytest.raises(RuntimeError, match="snapshot mismatch"):
|
|
send_card_notification._resolved_receivers(
|
|
"replenishment",
|
|
run_id,
|
|
["ou_newroute123"],
|
|
)
|
|
|
|
|
|
def test_supply_sender_uses_analyzer_bot_with_stable_business_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
sends: list[dict[str, object]] = []
|
|
|
|
def fake_send(**kwargs):
|
|
sends.append(kwargs)
|
|
return {"message_id": f"om_{len(sends)}"}
|
|
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setattr(send_card_notification, "send_lark_bot_message", fake_send)
|
|
|
|
send_card_notification._send_card(
|
|
"ou_first123",
|
|
{"elements": []},
|
|
"replenishment:run-1",
|
|
)
|
|
send_card_notification._send_card(
|
|
"ou_first123",
|
|
{"elements": []},
|
|
"replenishment:run-1",
|
|
)
|
|
send_card_notification._send_card(
|
|
"ou_second123",
|
|
{"elements": []},
|
|
"replenishment:run-1",
|
|
)
|
|
|
|
assert sends == [
|
|
{
|
|
"user_id": "ou_first123",
|
|
"content": {"elements": []},
|
|
"msg_type": "interactive",
|
|
"idempotency_key": "replenishment:run-1",
|
|
},
|
|
{
|
|
"user_id": "ou_first123",
|
|
"content": {"elements": []},
|
|
"msg_type": "interactive",
|
|
"idempotency_key": "replenishment:run-1",
|
|
},
|
|
{
|
|
"user_id": "ou_second123",
|
|
"content": {"elements": []},
|
|
"msg_type": "interactive",
|
|
"idempotency_key": "replenishment:run-1",
|
|
},
|
|
]
|
|
|
|
|
|
def test_supply_heartbeat_uses_collector_bot_and_collector_open_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
sends: list[dict[str, object]] = []
|
|
|
|
def forbidden_resolve(*_args, **_kwargs):
|
|
pytest.fail("heartbeat must not translate a collector-app open_id")
|
|
|
|
def fake_send(**kwargs):
|
|
sends.append(kwargs)
|
|
return {"message_id": "om_heartbeat1"}
|
|
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"resolve_notification_recipients",
|
|
forbidden_resolve,
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "send_lark_bot_message", fake_send)
|
|
result = {
|
|
"workflow": "replenishment",
|
|
"workflow_name": "replenishment 补货建议",
|
|
"workflow_id": "run-1",
|
|
"status": "running",
|
|
"current_phase": "collecting",
|
|
"heartbeat_response": "",
|
|
}
|
|
|
|
mcp_workflow._send_collector_heartbeat(result, phase="progress")
|
|
|
|
assert sends == [
|
|
{
|
|
"user_id": mcp_workflow.COLLECTOR_OWNER_OPEN_ID,
|
|
"content": mcp_workflow._build_heartbeat_card(result, phase="progress"),
|
|
"msg_type": "interactive",
|
|
"profile": mcp_workflow.COLLECTOR_FEISHU["app_id"],
|
|
"timeout": 30,
|
|
}
|
|
]
|
|
assert "feishu_msg_id=om_heartbeat1" in result["heartbeat_response"]
|
|
|
|
|
|
def test_purchase_order_update_notification_uses_chat_id_and_user_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
result_file = tmp_path / "update_result.json"
|
|
result_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"total_updated": 1,
|
|
"target_date": "2026-08-18",
|
|
"sku_list": ["SKU-1"],
|
|
"updated_records": [
|
|
{"sku": "SKU-1", "name": "旅行箱", "spec": "20寸黑色"}
|
|
],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
sends: list[dict[str, object]] = []
|
|
|
|
def fake_resolve(recipients, *, app_profile=None):
|
|
assert tuple(recipients) == (mcp_workflow.ANALYZER_OWNER_OPEN_ID,)
|
|
assert app_profile == "hermes-analyzer"
|
|
return ("ou_analyzerowner123",)
|
|
|
|
def fake_send(**kwargs):
|
|
sends.append(kwargs)
|
|
return {"message_id": f"om_delivery{len(sends)}"}
|
|
|
|
monkeypatch.setattr(mcp_workflow, "resolve_notification_recipients", fake_resolve)
|
|
monkeypatch.setattr(mcp_workflow, "send_lark_bot_message", fake_send)
|
|
|
|
receipt = mcp_workflow._send_purchase_order_update_notification(
|
|
[str(result_file)],
|
|
"purchase-order-update_20260810",
|
|
)
|
|
|
|
assert sends[0]["chat_id"] == mcp_workflow.SHENDAN_GROUP_CHAT_ID
|
|
assert "user_id" not in sends[0]
|
|
assert sends[1]["user_id"] == "ou_analyzerowner123"
|
|
assert "chat_id" not in sends[1]
|
|
assert all(send["profile"] == "hermes-analyzer" for send in sends)
|
|
assert all(send["msg_type"] == "interactive" for send in sends)
|
|
assert {
|
|
send["idempotency_key"] for send in sends
|
|
} == {"purchase-order-update:purchase-order-update_20260810"}
|
|
card_text = json.dumps(sends[0]["content"], ensure_ascii=False)
|
|
assert all(
|
|
value in card_text
|
|
for value in ("SKU-1", "旅行箱", "20寸黑色", "2026-08-18", "1")
|
|
)
|
|
assert receipt == {
|
|
"message_ids": ["om_delivery1", "om_delivery2"],
|
|
"recipient_message_ids": {
|
|
mcp_workflow.SHENDAN_GROUP_CHAT_ID: "om_delivery1",
|
|
"ou_analyzerowner123": "om_delivery2",
|
|
},
|
|
"errors": [],
|
|
}
|
|
|
|
|
|
def test_supply_analyzer_contract_has_no_feishu_message_plugin() -> None:
|
|
configured_steps = [
|
|
step
|
|
for workflow in supply_config.WORKFLOWS.values()
|
|
for step in workflow.get("analyzer_scripts", [])
|
|
]
|
|
|
|
assert "plugin:feishu.im.send_message" not in configured_steps
|
|
assert "adapter:send_lark_bot_message" in configured_steps
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"workflow_name",
|
|
["purchase-confirmation", "replenishment-alert", "replenishment"],
|
|
)
|
|
def test_supply_disabled_route_forbids_send_and_requires_empty_callback(
|
|
workflow_name: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_resolve_business_notification_route",
|
|
lambda _workflow_name: (False, ()),
|
|
)
|
|
|
|
prompt = mcp_workflow._build_analyze_prompt(workflow_name)
|
|
|
|
assert "动态通知配置已禁用" in prompt
|
|
assert "不得向任何用户或群聊发送业务消息" in prompt
|
|
assert "message_ids=[]" in prompt
|
|
assert "notification_disabled=True" in prompt
|
|
assert "Dynamic notification routing is disabled" in prompt
|
|
assert "No final business notification is required or allowed" in prompt
|
|
assert "must be sent as exactly ONE" not in prompt
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"workflow_name",
|
|
["purchase-confirmation", "replenishment-alert", "replenishment"],
|
|
)
|
|
def test_supply_enabled_prompt_forbids_llm_delivery_and_keeps_route_context(
|
|
workflow_name: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_resolve_business_notification_route",
|
|
_forbidden("prompt must preserve the route snapshot resolved at launch"),
|
|
)
|
|
|
|
prompt = mcp_workflow._build_analyze_prompt(
|
|
workflow_name,
|
|
notification_route=(True, ("ou_first", "ou_second")),
|
|
run_id="run-20260807",
|
|
)
|
|
|
|
assert "Hermes must not send any Feishu message" in prompt
|
|
assert "Python orchestrator alone delivers the final business card" in prompt
|
|
assert f"--workflow {workflow_name}" not in prompt
|
|
assert f"p.mark_notification_sent('{workflow_name}','run-20260807'" not in prompt
|
|
|
|
|
|
def test_notification_receipt_requires_persisted_message_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
pg_writer,
|
|
"notification_already_sent",
|
|
lambda *_args, **_kwargs: False,
|
|
)
|
|
assert not mcp_workflow._notification_receipt_exists("replenishment", "run-1")
|
|
|
|
monkeypatch.setattr(
|
|
pg_writer,
|
|
"notification_already_sent",
|
|
lambda *_args, **_kwargs: True,
|
|
)
|
|
assert mcp_workflow._notification_receipt_exists("replenishment", "run-1")
|
|
|
|
|
|
def test_notification_receipt_check_forwards_current_route_recipients(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_already_sent(workflow, run_id, *, expected_recipients=None):
|
|
captured.update(
|
|
workflow=workflow,
|
|
run_id=run_id,
|
|
expected_recipients=expected_recipients,
|
|
)
|
|
return True
|
|
|
|
monkeypatch.setattr(pg_writer, "notification_already_sent", fake_already_sent)
|
|
|
|
assert mcp_workflow._notification_receipt_exists(
|
|
"replenishment",
|
|
"run-routed",
|
|
("ou_first", "ou_second"),
|
|
)
|
|
assert captured == {
|
|
"workflow": "replenishment",
|
|
"run_id": "run-routed",
|
|
"expected_recipients": ("ou_first", "ou_second"),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mapping", "recipients", "expected"),
|
|
[
|
|
(
|
|
{"ou_first": "mid-first"},
|
|
("ou_first", "ou_second"),
|
|
False,
|
|
),
|
|
(
|
|
{"ou_first": "mid-first", "ou_second": "mid-second"},
|
|
("ou_first", "ou_second"),
|
|
True,
|
|
),
|
|
(
|
|
{"ou_first": "mid-first", "ou_second": "mid-second"},
|
|
("ou_new",),
|
|
False,
|
|
),
|
|
],
|
|
)
|
|
def test_notification_already_sent_requires_current_recipient_coverage(
|
|
mapping: dict[str, str],
|
|
recipients: tuple[str, ...],
|
|
expected: bool,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class FakeCursor:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def execute(self, _sql, _params):
|
|
return None
|
|
|
|
def fetchone(self):
|
|
return ({"notification_recipient_message_ids": mapping},)
|
|
|
|
class FakeConnection:
|
|
def cursor(self):
|
|
return FakeCursor()
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
|
|
|
assert (
|
|
pg_writer.notification_already_sent(
|
|
"replenishment",
|
|
"run-multi",
|
|
expected_recipients=recipients,
|
|
)
|
|
is expected
|
|
)
|
|
|
|
|
|
def test_notification_receipt_falls_back_to_zero_row_workflow_batch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class FakeCursor:
|
|
def __init__(self) -> None:
|
|
self.fetches = iter((None, None, (1,)))
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def execute(self, _sql: str, _params: object) -> None:
|
|
return None
|
|
|
|
def fetchone(self):
|
|
return next(self.fetches)
|
|
|
|
class FakeConnection:
|
|
def __init__(self) -> None:
|
|
self.cursor_instance = FakeCursor()
|
|
|
|
def cursor(self) -> FakeCursor:
|
|
return self.cursor_instance
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(pg_writer, "_conn", FakeConnection)
|
|
|
|
assert pg_writer.notification_already_sent(
|
|
"purchase-confirmation", "zero-row-run"
|
|
)
|
|
|
|
|
|
def test_production_mode_preserves_the_existing_lock_first_flow(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_acceptance_cookie_preflight",
|
|
_forbidden("production mode must not run the acceptance preflight"),
|
|
)
|
|
lock_calls: list[tuple[str, str]] = []
|
|
|
|
def reject_lock(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
|
|
lock_calls.append((workflow_name, workflow_id))
|
|
return False, "already running"
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", reject_lock)
|
|
|
|
result = mcp_workflow.run_mcp_workflow("replenishment", "production-run")
|
|
|
|
assert result["status"] == "failed"
|
|
assert result["error"] == "already running"
|
|
assert lock_calls == [("replenishment", "production-run")]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "expected"),
|
|
[
|
|
("completed", 0),
|
|
("skipped_cookie", COOKIE_SKIP_EXIT_CODE),
|
|
("blocked_business_mutation", runner.BLOCKED_EXIT_CODE),
|
|
("failed", 1),
|
|
],
|
|
)
|
|
def test_both_supply_clis_propagate_structured_result_exit_codes(
|
|
status: str,
|
|
expected: int,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
result = {"workflow": "replenishment", "status": status}
|
|
monkeypatch.setattr(runner, "run_mcp", lambda _workflow: result)
|
|
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
["runner.py", "mcp", "replenishment"],
|
|
)
|
|
assert runner.main() == expected
|
|
|
|
monkeypatch.setattr(
|
|
sys,
|
|
"argv",
|
|
["run.py", "mcp-run", "replenishment"],
|
|
)
|
|
assert supply_run.main() == expected
|
|
capsys.readouterr()
|
|
|
|
|
|
def test_batch_exit_code_uses_failure_then_block_then_cookie_precedence() -> None:
|
|
assert runner.result_exit_code(
|
|
{
|
|
"results": [
|
|
{"status": "completed"},
|
|
{"status": "skipped_cookie"},
|
|
]
|
|
}
|
|
) == COOKIE_SKIP_EXIT_CODE
|
|
assert runner.result_exit_code(
|
|
{
|
|
"results": [
|
|
{"status": "skipped_cookie"},
|
|
{"status": "blocked_business_mutation"},
|
|
]
|
|
}
|
|
) == runner.BLOCKED_EXIT_CODE
|
|
assert runner.result_exit_code(
|
|
{
|
|
"results": [
|
|
{"status": "blocked_business_mutation"},
|
|
{"status": "failed"},
|
|
]
|
|
}
|
|
) == 1
|
|
|
|
|
|
def test_idempotent_replay_uses_a_distinct_skip_exit_code() -> None:
|
|
assert runner.result_exit_code(
|
|
{
|
|
"workflow": "purchase-confirmation",
|
|
"status": "completed",
|
|
"idempotent_replay": True,
|
|
}
|
|
) == runner.IDEMPOTENT_REPLAY_EXIT_CODE
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"])
|
|
def test_force_refresh_environment_values(
|
|
value: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GYXX_FORCE_REFRESH", value)
|
|
assert mcp_workflow._force_refresh_requested() is True
|
|
|
|
|
|
def test_force_refresh_is_opt_in(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GYXX_FORCE_REFRESH", raising=False)
|
|
assert mcp_workflow._force_refresh_requested() is False
|
|
|
|
|
|
def test_force_refresh_recollects_and_notifies_deterministically_instead_of_reusing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.setenv("GYXX_FORCE_REFRESH", "true")
|
|
collected_file = tmp_path / "purchase-confirmation.csv"
|
|
collected_file.write_text("sku\n1001\n", encoding="utf-8")
|
|
collection_calls: list[str] = []
|
|
notification_calls: list[tuple[str, str, list[str]]] = []
|
|
receipt_calls: list[tuple[str, str, tuple[str, ...] | None]] = []
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", lambda *_args: (True, ""))
|
|
monkeypatch.setattr(mcp_workflow, "_release_workflow_lock", lambda *_args: None)
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_collection_lock", lambda *_args: (True, ""))
|
|
monkeypatch.setattr(mcp_workflow, "_release_collection_lock", lambda *_args: None)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_reusable_collected_files",
|
|
_forbidden("force refresh must not inspect reusable collection files"),
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "_clear_shared_outputs", lambda *_args: [])
|
|
|
|
def collect(workflow_name: str, workflow_id: str) -> tuple[bool, str]:
|
|
collection_calls.append(f"{workflow_name}:{workflow_id}")
|
|
return True, "fresh collection"
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_run_local_collection_script", collect)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_ensure_shared_outputs",
|
|
lambda *_args: [str(collected_file)],
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "_validate_run_manifest", lambda *_args: (True, ""))
|
|
monkeypatch.setattr(mcp_workflow, "_extract_pending_run_id", lambda *_args: None)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_load_or_create_notification_snapshot",
|
|
lambda *_args: (True, ("ou_test",), False),
|
|
)
|
|
|
|
def receipt_exists(
|
|
workflow_name: str,
|
|
run_id: str,
|
|
recipients: tuple[str, ...] | None = None,
|
|
) -> bool:
|
|
receipt_calls.append((workflow_name, run_id, recipients))
|
|
return True
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_notification_receipt_exists", receipt_exists)
|
|
|
|
def send_purchase_confirmation(workflow_name, workflow_run_id, collected_files):
|
|
notification_calls.append((workflow_name, workflow_run_id, list(collected_files)))
|
|
return {
|
|
"message_ids": ["om_test"],
|
|
"recipient_message_ids": {"ou_test": "om_test"},
|
|
"errors": [],
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_purchase_confirmation_notification",
|
|
send_purchase_confirmation,
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "_send_collector_heartbeat", lambda *_args, **_kwargs: "")
|
|
monkeypatch.setattr(mcp_workflow, "_start_progress_heartbeat_worker", lambda *_args: None)
|
|
monkeypatch.setattr(mcp_workflow, "_close_workflow_browser", lambda *_args: [])
|
|
monkeypatch.setattr(mcp_workflow, "_cleanup_workflow_artifacts", lambda *_args: [])
|
|
monkeypatch.setattr(pg_writer, "upsert_run", lambda **_kwargs: None)
|
|
|
|
result = mcp_workflow.run_mcp_workflow(
|
|
"purchase-confirmation",
|
|
"purchase-confirmation_20260814",
|
|
)
|
|
|
|
assert result["status"] == "completed"
|
|
assert result["force_refresh"] is True
|
|
assert result["collection_reused"] is False
|
|
assert collection_calls == [
|
|
"purchase-confirmation:purchase-confirmation_20260814"
|
|
]
|
|
assert notification_calls == [
|
|
(
|
|
"purchase-confirmation",
|
|
"purchase-confirmation_20260814",
|
|
[str(collected_file)],
|
|
)
|
|
]
|
|
assert receipt_calls == [
|
|
(
|
|
"purchase-confirmation",
|
|
"purchase-confirmation_20260814",
|
|
("ou_test",),
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"script_name",
|
|
["collect_confirmation.ps1", "collect_replenishment.ps1"],
|
|
)
|
|
def test_direct_collection_wrappers_gate_before_browser_start(
|
|
script_name: str,
|
|
) -> None:
|
|
source = (SUPPLY_WRAPPERS / script_name).read_text(encoding="utf-8-sig")
|
|
|
|
acceptance_position = source.index('if ($env:GYXX_WORKFLOW_ACCEPTANCE -eq "1")')
|
|
gate_position = source.index("[SKIPPED_COOKIE]")
|
|
browser_start_position = source.index("Start-Process -FilePath $chromePath")
|
|
assert gate_position < browser_start_position
|
|
assert "exit 75" in source[gate_position:browser_start_position]
|
|
assert '$forceFreshBrowser = $false' in source[gate_position - 200:browser_start_position]
|
|
assert "$env:GYXX_SUPPLY_ERP_PASSWORD" in source[
|
|
acceptance_position:browser_start_position
|
|
]
|
|
assert "starting the target-owned profile for credential fallback" in source
|
|
|
|
|
|
def test_direct_purchase_order_wrapper_blocks_before_task_and_browser_access() -> None:
|
|
source = (SUPPLY_WRAPPERS / "collect_purchase_order_update.ps1").read_text(
|
|
encoding="utf-8-sig"
|
|
)
|
|
|
|
gate_position = source.index("[BLOCKED_BUSINESS_MUTATION]")
|
|
assert gate_position < source.index("$taskFile =")
|
|
assert gate_position < source.index("Start-Process -FilePath $chromePath")
|
|
assert "exit 77" in source[gate_position:source.index("$scriptDir =")]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("script_name", "expected_code", "expected_marker"),
|
|
[
|
|
("collect_confirmation.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
|
|
("collect_replenishment.ps1", COOKIE_SKIP_EXIT_CODE, "SKIPPED_COOKIE"),
|
|
(
|
|
"collect_purchase_order_update.ps1",
|
|
runner.BLOCKED_EXIT_CODE,
|
|
"BLOCKED_BUSINESS_MUTATION",
|
|
),
|
|
],
|
|
)
|
|
def test_direct_wrapper_process_never_calls_start_process_when_gated(
|
|
script_name: str,
|
|
expected_code: int,
|
|
expected_marker: str,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
powershell = shutil.which("powershell.exe")
|
|
if powershell is None:
|
|
pytest.skip("Windows PowerShell is unavailable")
|
|
|
|
target = SUPPLY_WRAPPERS / script_name
|
|
escaped_target = str(target).replace("'", "''")
|
|
command = "\n".join(
|
|
[
|
|
"function global:Get-NetTCPConnection {",
|
|
" param([int]$LocalPort, [string]$State)",
|
|
" return $null",
|
|
"}",
|
|
"function global:Start-Process { throw 'START_PROCESS_CALLED' }",
|
|
f"& '{escaped_target}'",
|
|
"$code = $LASTEXITCODE",
|
|
"exit $code",
|
|
]
|
|
)
|
|
environment = os.environ.copy()
|
|
environment.update(
|
|
{
|
|
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
|
"GYXX_PROJECT_ROOT": str(PROJECT_ROOT),
|
|
"GYXX_DATA_ROOT": str(tmp_path / "data"),
|
|
}
|
|
)
|
|
|
|
completed = subprocess.run(
|
|
[
|
|
powershell,
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
command,
|
|
],
|
|
cwd=PROJECT_ROOT,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
|
|
output = completed.stdout + completed.stderr
|
|
assert completed.returncode == expected_code, output
|
|
assert expected_marker in output
|
|
assert "START_PROCESS_CALLED" not in output
|
|
|
|
|
|
def test_replenishment_alert_card_builds_deterministic_content() -> None:
|
|
payload = {
|
|
"alert_count": 1,
|
|
"generated_at": "2026-08-18 07:00:45",
|
|
"alerts": [
|
|
{
|
|
"sku": "10362011",
|
|
"threshold": 300,
|
|
"actual_stock": 184.0,
|
|
"purchase_all": 0.0,
|
|
"product_name": "L型数据线",
|
|
"product_spec": "弯头磁吸线CTOC;活力橙色 100W",
|
|
"sales_7": 28.0,
|
|
"sales_15": 46.0,
|
|
"sales_30": 92.0,
|
|
}
|
|
],
|
|
}
|
|
|
|
card = mcp_workflow._replenishment_alert_card(payload)
|
|
|
|
assert card["header"]["template"] == "red"
|
|
assert card["header"]["title"]["content"] == "库存阈值预警"
|
|
text = json.dumps(card, ensure_ascii=False)
|
|
assert "10362011" in text
|
|
assert "L型数据线" in text
|
|
assert "库存缺口" in text
|
|
assert "116" in text
|
|
assert "92" in text
|
|
|
|
|
|
def test_replenishment_alert_notification_disabled_records_disabled_receipt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
run_id = "run-disabled"
|
|
collected = tmp_path / "threshold_alert_data.json"
|
|
collected.write_text(
|
|
json.dumps(
|
|
{
|
|
"alert_count": 1,
|
|
"alerts": [
|
|
{
|
|
"sku": "10362011",
|
|
"threshold": 300,
|
|
"actual_stock": 184.0,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path / "state")
|
|
snapshot = mcp_workflow._notification_snapshot_path(
|
|
"replenishment-alert",
|
|
run_id,
|
|
)
|
|
snapshot.parent.mkdir(parents=True)
|
|
snapshot.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": "replenishment-alert",
|
|
"run_id": run_id,
|
|
"enabled": False,
|
|
"recipients": [],
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
|
|
|
def fake_mark_notification_sent(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
|
|
monkeypatch.setattr(
|
|
pg_writer,
|
|
"mark_notification_sent",
|
|
fake_mark_notification_sent,
|
|
)
|
|
|
|
result = mcp_workflow._send_replenishment_alert_notification(
|
|
"replenishment-alert",
|
|
run_id,
|
|
[str(collected)],
|
|
)
|
|
|
|
assert result["notification_disabled"] is True
|
|
assert len(calls) == 1
|
|
_, kwargs = calls[0]
|
|
assert kwargs["notification_disabled"] is True
|
|
assert kwargs["message_ids"] == []
|
|
assert kwargs["expected_recipients"] == []
|
|
|
|
|
|
def test_replenishment_alert_notification_sends_and_returns_receipt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
run_id = "run-enabled"
|
|
recipients = ["ou_recipient0001", "ou_recipient0002"]
|
|
collected = tmp_path / "threshold_alert_data.json"
|
|
collected.write_text(
|
|
json.dumps(
|
|
{
|
|
"alert_count": 1,
|
|
"alerts": [
|
|
{
|
|
"sku": "10362011",
|
|
"threshold": 300,
|
|
"actual_stock": 184.0,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path / "state")
|
|
snapshot = mcp_workflow._notification_snapshot_path(
|
|
"replenishment-alert",
|
|
run_id,
|
|
)
|
|
snapshot.parent.mkdir(parents=True)
|
|
snapshot.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"workflow": "replenishment-alert",
|
|
"run_id": run_id,
|
|
"enabled": True,
|
|
"recipients": recipients,
|
|
"acceptance": False,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_send_card_run(
|
|
receivers,
|
|
card,
|
|
*,
|
|
workflow,
|
|
run_id,
|
|
idempotency_key,
|
|
):
|
|
captured.update(
|
|
receivers=list(receivers),
|
|
card=card,
|
|
workflow=workflow,
|
|
run_id=run_id,
|
|
idempotency_key=idempotency_key,
|
|
)
|
|
return {
|
|
"message_ids": ["om_1", "om_2"],
|
|
"recipient_message_ids": {
|
|
"ou_recipient0001": "om_1",
|
|
"ou_recipient0002": "om_2",
|
|
},
|
|
"errors": [],
|
|
}
|
|
|
|
monkeypatch.setattr(send_card_notification, "run", fake_send_card_run)
|
|
|
|
result = mcp_workflow._send_replenishment_alert_notification(
|
|
"replenishment-alert",
|
|
run_id,
|
|
[str(collected)],
|
|
)
|
|
|
|
assert result["message_ids"] == ["om_1", "om_2"]
|
|
assert captured["receivers"] == recipients
|
|
assert captured["workflow"] == "replenishment-alert"
|
|
assert captured["run_id"] == run_id
|
|
assert captured["idempotency_key"] == f"replenishment-alert:{run_id}"
|
|
assert captured["card"]["header"]["template"] == "red"
|
|
|
|
|
|
def test_replenishment_alert_workflow_uses_deterministic_notification(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
monkeypatch.delenv("GYXX_FORCE_REFRESH", raising=False)
|
|
run_id = "replenishment-alert_20260818"
|
|
collected_file = tmp_path / "threshold_alert_data.json"
|
|
collected_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"run_id": run_id,
|
|
"alert_count": 1,
|
|
"alerts": [
|
|
{
|
|
"sku": "10362011",
|
|
"threshold": 300,
|
|
"actual_stock": 184.0,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", lambda *_args: (True, ""))
|
|
monkeypatch.setattr(mcp_workflow, "_release_workflow_lock", lambda *_args: None)
|
|
monkeypatch.setattr(mcp_workflow, "_acquire_collection_lock", lambda *_args: (True, ""))
|
|
monkeypatch.setattr(mcp_workflow, "_release_collection_lock", lambda *_args: None)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_reusable_collected_files",
|
|
lambda *_args: [],
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "_clear_shared_outputs", lambda *_args: [])
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_run_local_collection_script",
|
|
lambda *_args: (True, "fresh collection"),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_ensure_shared_outputs",
|
|
lambda *_args: [str(collected_file)],
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_validate_run_manifest",
|
|
lambda *_args: (True, ""),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_extract_pending_run_id",
|
|
lambda *_args: run_id,
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_load_or_create_notification_snapshot",
|
|
lambda *_args: (True, ("ou_test_recipient",), False),
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_notification_receipt_exists",
|
|
lambda *_args: False,
|
|
)
|
|
|
|
deterministic_calls: list[tuple[str, str, list[str]]] = []
|
|
|
|
def fake_send_notification(workflow_name, workflow_run_id, collected_files):
|
|
deterministic_calls.append(
|
|
(workflow_name, workflow_run_id, list(collected_files))
|
|
)
|
|
return {
|
|
"message_ids": ["om_1"],
|
|
"recipient_message_ids": {"ou_test_recipient": "om_1"},
|
|
"errors": [],
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_replenishment_alert_notification",
|
|
fake_send_notification,
|
|
)
|
|
|
|
analyzer_calls: list[str] = []
|
|
|
|
def fake_analyze(role: str, _prompt: str):
|
|
analyzer_calls.append(role)
|
|
return True, 1, "analyzer must not run for replenishment-alert"
|
|
|
|
monkeypatch.setattr(mcp_workflow, "_call_once", fake_analyze)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_send_collector_heartbeat",
|
|
lambda *_args, **_kwargs: "",
|
|
)
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_start_progress_heartbeat_worker",
|
|
lambda *_args: None,
|
|
)
|
|
monkeypatch.setattr(mcp_workflow, "_close_workflow_browser", lambda *_args: [])
|
|
monkeypatch.setattr(mcp_workflow, "_cleanup_workflow_artifacts", lambda *_args: [])
|
|
monkeypatch.setattr(pg_writer, "upsert_run", lambda **_kwargs: None)
|
|
|
|
result = mcp_workflow.run_mcp_workflow("replenishment-alert", run_id)
|
|
|
|
assert result["status"] == "completed"
|
|
assert result["current_phase"] == "finishing"
|
|
assert result["notification_receipt"]["message_ids"] == ["om_1"]
|
|
assert deterministic_calls == [
|
|
("replenishment-alert", run_id, [str(collected_file)])
|
|
]
|
|
assert analyzer_calls == []
|
|
|
|
|
|
def test_purchase_confirmation_notification_uses_collector_csv_and_receipt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
run_id = "purchase-confirmation_20260822"
|
|
collected = tmp_path / "purchase-confirmation.csv"
|
|
collected.write_text(
|
|
"商品编码,清洗后的名称,清洗后的规格,可用数,7天销量,采购在途数,汇总原始数据,缺口,协议到货时间\n"
|
|
"'10483001,宙斯ZAir,大号;曜夜黑,108,698.0,1756.0,宙斯Z Air,-590.0,2026-08-21 超时2\n",
|
|
encoding="utf-8",
|
|
)
|
|
recipients = ("ou_test_recipient",)
|
|
captured: dict[str, object] = {}
|
|
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_load_or_create_notification_snapshot",
|
|
lambda *_args: (True, recipients, False),
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import (
|
|
send_card_notification,
|
|
)
|
|
|
|
def fake_send_card_run(receivers, card, **kwargs):
|
|
captured.update(receivers=receivers, card=card, **kwargs)
|
|
return {
|
|
"message_ids": ["om_confirmation"],
|
|
"recipient_message_ids": {recipients[0]: "om_confirmation"},
|
|
"errors": [],
|
|
}
|
|
|
|
monkeypatch.setattr(send_card_notification, "run", fake_send_card_run)
|
|
|
|
receipt = mcp_workflow._send_purchase_confirmation_notification(
|
|
"purchase-confirmation", run_id, [str(collected)]
|
|
)
|
|
|
|
assert receipt["message_ids"] == ["om_confirmation"]
|
|
assert captured["receivers"] == list(recipients)
|
|
assert captured["workflow"] == "purchase-confirmation"
|
|
assert captured["run_id"] == run_id
|
|
assert captured["idempotency_key"] == f"purchase-confirmation:{run_id}"
|
|
card = captured["card"]
|
|
assert card["header"]["title"]["content"] == "采购确认通知"
|
|
body = "\n".join(item["text"]["content"] for item in card["elements"] if item["tag"] == "div")
|
|
assert "10483001" in body
|
|
assert "宙斯ZAir" in body
|
|
assert "大号;曜夜黑" in body
|
|
assert "108" in body
|
|
assert "-590.0" in body
|
|
assert "2026-08-21 超时2" in body
|
|
|
|
|
|
def test_replenishment_notification_uses_collector_payloads_and_receipt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
run_id = "replenishment_20260822"
|
|
pending = tmp_path / "pending_insert.json"
|
|
notify = tmp_path / "notify_data.json"
|
|
pending.write_text(
|
|
json.dumps(
|
|
{
|
|
"records": [
|
|
{
|
|
"商品编号": "10483001",
|
|
"商品名称": "宙斯ZAir",
|
|
"规格名称(新)": "大号;曜夜黑",
|
|
"补货量": 300,
|
|
"实际可用数": 18,
|
|
"采购在途": 50,
|
|
},
|
|
{"商品编号": "10483002", "补货量": float("nan")},
|
|
]
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
notify.write_text(
|
|
json.dumps({"pending_count": 1, "skipped_count": 2}, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
recipients = ("ou_test_recipient",)
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(
|
|
mcp_workflow,
|
|
"_load_or_create_notification_snapshot",
|
|
lambda *_args: (True, recipients, False),
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import (
|
|
send_card_notification,
|
|
)
|
|
|
|
def fake_send_card_run(receivers, card, **kwargs):
|
|
captured.update(receivers=receivers, card=card, **kwargs)
|
|
return {
|
|
"message_ids": ["om_replenishment"],
|
|
"recipient_message_ids": {recipients[0]: "om_replenishment"},
|
|
"errors": [],
|
|
}
|
|
|
|
monkeypatch.setattr(send_card_notification, "run", fake_send_card_run)
|
|
|
|
receipt = mcp_workflow._send_replenishment_notification(
|
|
"replenishment", run_id, [str(pending), str(notify)]
|
|
)
|
|
|
|
assert receipt["message_ids"] == ["om_replenishment"]
|
|
assert captured["receivers"] == list(recipients)
|
|
assert captured["workflow"] == "replenishment"
|
|
assert captured["idempotency_key"] == f"replenishment:{run_id}"
|
|
card = captured["card"]
|
|
assert card["header"]["title"]["content"] == "补货建议通知"
|
|
body = "\n".join(item["text"]["content"] for item in card["elements"] if item["tag"] == "div")
|
|
assert "10483001" in body
|
|
assert "宙斯ZAir" in body
|
|
assert "大号;曜夜黑" in body
|
|
assert "建议补货:300" in body
|