Files
gyxx-flow/tests/test_module_command_adapter.py
T

441 lines
15 KiB
Python

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"
)