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 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, ) 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"), ) 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_receipt_requires_persisted_message_id( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: False) assert not mcp_workflow._notification_receipt_exists("replenishment", "run-1") monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: True) assert mcp_workflow._notification_receipt_exists("replenishment", "run-1") 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 @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