441 lines
15 KiB
Python
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"
|
|
)
|