from __future__ import annotations import json import shutil import sys from datetime import datetime, timezone from pathlib import Path import pytest from gyxx_flow.adapters.acceptance_policy import ( WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID, WorkflowAcceptancePolicyError, ) from gyxx_flow.adapters.native import ( DeferredModuleCommandStep, ModuleCommandAdapter, ModuleSourceError, ModuleSourceRoots, ) from gyxx_flow.catalog import WorkflowEntry from gyxx_flow.core.config import Settings from gyxx_flow.core.context import RunContext from gyxx_flow.notification_routing import ( ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, GYXX_NOTIFICATION_APP_PROFILE, GYXX_NOTIFICATION_HERMES_PROFILE, GYXX_NOTIFICATION_RECIPIENTS_JSON, GYXX_NOTIFICATION_ROUTE_MODE, GYXX_NOTIFICATION_ROUTE_REVISION, GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, NotificationRoutingStore, resolve_notification_route, ) from gyxx_flow.workflow.steps import StepExecution PROJECT_ROOT = Path(__file__).resolve().parents[1] def _entry(**overrides: object) -> WorkflowEntry: values: dict[str, object] = { "workflow_id": "product.backfill", "module": "product_commerce", "trigger": "manual", "entry": "jobs/backfill.py", "args": ("--limit", "25"), "source_project": "product", } values.update(overrides) return WorkflowEntry(**values) # type: ignore[arg-type] def _context() -> RunContext: return RunContext.create( "product.backfill", "2026-07-27", now=datetime(2026, 7, 27, tzinfo=timezone.utc), random_suffix="native1", ) def _context_for(workflow_id: str) -> RunContext: return RunContext.create( workflow_id, "2026-07-27", now=datetime(2026, 7, 27, tzinfo=timezone.utc), random_suffix="native2", ) def test_module_adapter_resolves_only_inside_new_module_root(tmp_path: Path) -> None: root = tmp_path / "product_commerce" / "runtime" entry = root / "jobs" / "backfill.py" entry.parent.mkdir(parents=True) entry.write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), python_executable=sys.executable, base_env={"PATH": "portable"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(), context=_context()) assert command.argv == (sys.executable, str(entry.resolve()), "--limit", "25") assert command.cwd == root.resolve() assert command.env["GYXX_PROJECT_ROOT"] == str(tmp_path.resolve()) assert command.env["GYXX_DATA_ROOT"] == str((tmp_path / "var").resolve()) assert command.env["GYXX_MODULE_ROOT"] == str(root.resolve()) assert not any(name.startswith("GYXX_LEGACY_") for name in command.env) @pytest.mark.parametrize("entry", ("../outside.py", "/outside.py", "C:/outside.py")) def test_module_adapter_rejects_paths_outside_module(tmp_path: Path, entry: str) -> None: root = tmp_path / "runtime" root.mkdir() adapter = ModuleCommandAdapter(ModuleSourceRoots({"product_commerce": root})) with pytest.raises(ModuleSourceError, match="relative|escape"): adapter.build(_entry(entry=entry), context=_context()) def test_deferred_module_step_dry_run_never_resolves_or_executes() -> None: def fail(*args: object, **kwargs: object) -> object: raise AssertionError("dry-run must not resolve or execute module source") execution = DeferredModuleCommandStep(_entry(), command_factory=fail).execute( context=_context(), timeout_seconds=60, dry_run=True, ) assert execution.exit_code == 0 assert execution.skipped is True assert execution.reason == "dry-run" def test_deferred_module_step_renders_business_date_in_catalog_args() -> None: captured: list[WorkflowEntry] = [] class SuccessStep: def execute(self, **_kwargs: object) -> StepExecution: return StepExecution(exit_code=0) def command_factory(entry: WorkflowEntry, _context: RunContext) -> SuccessStep: captured.append(entry) return SuccessStep() execution = DeferredModuleCommandStep( _entry(), command_factory=command_factory, command_args=("--start-date", "{business_date}", "run={run_id}"), ).execute(context=_context(), timeout_seconds=60, dry_run=False) assert execution.exit_code == 0 assert captured[0].args == ( "--start-date", "2026-07-27", f"run={_context().run_id}", ) def test_module_adapter_uses_explicit_windows_launchers_for_batch_and_powershell( tmp_path: Path, ) -> None: root = tmp_path / "runtime" root.mkdir() (root / "daily.bat").write_text("@echo off", encoding="utf-8") (root / "maint.ps1").write_text("Write-Output ok", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={}, project_root=tmp_path, data_root=tmp_path / "var", ) batch = adapter.build(_entry(entry="daily.bat", args=()), context=_context()) powershell = adapter.build( _entry(entry="maint.ps1", args=()), context=_context() ) assert batch.argv[:4] == ("cmd.exe", "/d", "/s", "/c") assert str((root / "daily.bat").resolve()) in batch.argv[4] assert powershell.argv[:5] == ( "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ) def test_product_config_uses_only_the_new_environment_name(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={"GYXX_PRODUCT_CONFIG": "D:/portable/product.json"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/portable/product.json" assert "AUTO_FLOW_CONFIG" not in command.env def test_product_config_defaults_to_canonical_module_state(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") data_root = tmp_path / "var" adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={}, project_root=tmp_path, data_root=data_root, ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == str( (data_root / "state" / "product_commerce" / "auto-flow-config.json").resolve() ) def test_product_config_maps_legacy_portable_alias(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={"AUTOFLOW_CONFIG_PATH": "D:/portable/legacy-product.json"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/portable/legacy-product.json" def test_product_config_maps_original_source_environment_name(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={"AUTO_FLOW_CONFIG": "D:/original/product.json"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/original/product.json" def test_product_config_uses_explicit_precedence_order(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={ "GYXX_PRODUCT_CONFIG": "D:/canonical/product.json", "AUTO_FLOW_CONFIG": "D:/original/product.json", "AUTOFLOW_CONFIG_PATH": "D:/portable/product.json", }, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_PRODUCT_CONFIG"] == "D:/canonical/product.json" def test_module_adapter_propagates_fail_closed_acceptance_policy( tmp_path: Path, ) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={"GYXX_WORKFLOW_ACCEPTANCE": "1"}, project_root=tmp_path, data_root=tmp_path / "var", ) command = adapter.build(_entry(entry="job.py", args=()), context=_context()) assert command.env["GYXX_FEISHU_TABLE_WRITE_DISABLED"] == "1" assert command.env["GYXX_COOKIE_INVALID_SKIP"] == "1" assert ( command.env["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID ) def test_module_adapter_rejects_unsafe_acceptance_recipient(tmp_path: Path) -> None: root = tmp_path / "runtime" root.mkdir() (root / "job.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": root}), base_env={ "GYXX_WORKFLOW_ACCEPTANCE": "1", "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": "ou_not_wang", }, project_root=tmp_path, data_root=tmp_path / "var", ) with pytest.raises(WorkflowAcceptancePolicyError, match="Wang Yunlong"): adapter.build(_entry(entry="job.py", args=()), context=_context()) def test_module_adapter_injects_stable_notification_route_before_acceptance( tmp_path: Path, ) -> None: project_root = tmp_path / "project" config_root = project_root / "config" config_root.mkdir(parents=True) shutil.copyfile( PROJECT_ROOT / "config" / "notification-routing.json", config_root / "notification-routing.json", ) data_root = tmp_path / "data" settings = Settings(project_root=project_root, data_root=data_root) store = NotificationRoutingStore(settings) payload, revision = store.snapshot() payload["routes"]["content.marketing_report.daily"] = { "enabled": True, "app_profile": "hermes-analyzer", "person_ids": ["he_yingwei", "wang_yunlong"], } store.update(payload, expected_revision=revision) module_root = tmp_path / "content_marketing" module_root.mkdir() (module_root / "daily.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"content_marketing": module_root}), base_env={"GYXX_WORKFLOW_ACCEPTANCE": "1"}, project_root=project_root, data_root=data_root, ) entry = _entry( workflow_id="content.marketing_report.daily", module="content_marketing", entry="daily.py", args=(), ) context = RunContext.create( entry.workflow_id, "2026-07-27", now=datetime(2026, 7, 27, tzinfo=timezone.utc), random_suffix="notify1", ) command = adapter.build(entry, context=context) assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "enabled" assert command.env[GYXX_NOTIFICATION_APP_PROFILE] == "hermes-analyzer" assert json.loads(command.env[GYXX_NOTIFICATION_RECIPIENTS_JSON]) == [ ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID ] assert ( command.env["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID ) assert resolve_notification_route(entry.workflow_id, (), command.env).open_ids == ( ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, ) def test_module_adapter_strips_route_snapshot_from_synthetic_manual_command( tmp_path: Path, ) -> None: module_root = tmp_path / "product_commerce" module_root.mkdir() (module_root / "run_alerts.py").write_text("pass", encoding="utf-8") adapter = ModuleCommandAdapter( ModuleSourceRoots({"product_commerce": module_root}), base_env={ GYXX_NOTIFICATION_ROUTE_MODE: "enabled", GYXX_NOTIFICATION_APP_PROFILE: "hermes-analyzer", GYXX_NOTIFICATION_HERMES_PROFILE: "data-analyzer", GYXX_NOTIFICATION_RECIPIENTS_JSON: ( '["ou_89bcff110ccbb09a23548dc0fb3d880c"]' ), GYXX_NOTIFICATION_ROUTE_REVISION: "0" * 64, GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID: "content.marketing_report.daily", }, project_root=tmp_path, data_root=tmp_path / "var", ) synthetic_id = "script.product_commerce.0123456789ab" command = adapter.build( _entry( workflow_id=synthetic_id, entry="run_alerts.py", args=(), ), context=_context_for(synthetic_id), ) for variable in ( GYXX_NOTIFICATION_ROUTE_MODE, GYXX_NOTIFICATION_APP_PROFILE, GYXX_NOTIFICATION_HERMES_PROFILE, GYXX_NOTIFICATION_RECIPIENTS_JSON, GYXX_NOTIFICATION_ROUTE_REVISION, GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, ): assert variable not in command.env def test_module_adapter_snapshots_declared_route_for_public_manual_command( tmp_path: Path, ) -> None: project_root = tmp_path / "project" config_root = project_root / "config" config_root.mkdir(parents=True) shutil.copyfile( PROJECT_ROOT / "config" / "notification-routing.json", config_root / "notification-routing.json", ) module_root = tmp_path / "content_marketing" module_root.mkdir() (module_root / "daily.py").write_text("pass", encoding="utf-8") synthetic_id = "script.content_marketing.0123456789ab" adapter = ModuleCommandAdapter( ModuleSourceRoots({"content_marketing": module_root}), project_root=project_root, data_root=tmp_path / "data", ) command = adapter.build( _entry( workflow_id=synthetic_id, module="content_marketing", entry="daily.py", args=(), notification_workflow_id="content.marketing_report.daily", ), context=_context_for(synthetic_id), ) assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "legacy" assert ( command.env[GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID] == "content.marketing_report.daily" )