Files
gyxx-flow/tests/test_legacy_adapter.py
T

222 lines
7.4 KiB
Python

from __future__ import annotations
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
from gyxx_flow.catalog import WorkflowEntry
from gyxx_flow.core.context import RunContext
from gyxx_flow.migration.legacy import (
LegacyCommandAdapter,
LegacyConfigurationError,
LegacyProjectRoots,
)
PROJECT_IDS = ("content", "shop", "product", "supply")
def _entry(**overrides: object) -> WorkflowEntry:
values: dict[str, object] = {
"workflow_id": "product.backfill",
"module": "product_commerce",
"trigger": "manual",
"source_project": "product",
"entry": "jobs/backfill.py",
"args": ("--limit", "25"),
}
values.update(overrides)
return WorkflowEntry(**values) # type: ignore[arg-type]
def _context(*, shadow: bool = True) -> RunContext:
return RunContext.create(
"product.backfill",
"2026-07-27",
shadow=shadow,
now=datetime(2026, 7, 27, 2, 30, tzinfo=timezone.utc),
random_suffix="abc123",
)
def _project_roots(tmp_path: Path) -> dict[str, Path]:
roots = {project_id: tmp_path / project_id for project_id in PROJECT_IDS}
for root in roots.values():
root.mkdir()
return roots
def test_legacy_roots_are_loaded_only_from_injected_environment(tmp_path: Path) -> None:
roots = _project_roots(tmp_path)
env = {
f"GYXX_LEGACY_{project_id.upper()}_ROOT": str(root)
for project_id, root in roots.items()
}
configured = LegacyProjectRoots.from_env(env=env)
assert configured.as_dict() == {
project_id: root.resolve() for project_id, root in roots.items()
}
def test_legacy_roots_report_all_missing_environment_keys(tmp_path: Path) -> None:
content_root = tmp_path / "content"
content_root.mkdir()
with pytest.raises(LegacyConfigurationError) as captured:
LegacyProjectRoots.from_env(
env={"GYXX_LEGACY_CONTENT_ROOT": str(content_root)}
)
message = str(captured.value)
assert "GYXX_LEGACY_SHOP_ROOT" in message
assert "GYXX_LEGACY_PRODUCT_ROOT" in message
assert "GYXX_LEGACY_SUPPLY_ROOT" in message
def test_explicit_root_configuration_rejects_missing_or_non_directory_paths(
tmp_path: Path,
) -> None:
missing = tmp_path / "missing"
file_path = tmp_path / "not-a-directory"
file_path.write_text("x", encoding="utf-8")
with pytest.raises(LegacyConfigurationError, match="does not exist"):
LegacyProjectRoots({"product": missing})
with pytest.raises(LegacyConfigurationError, match="not a directory"):
LegacyProjectRoots({"product": file_path})
def test_adapter_builds_python_command_and_injects_trace_context(tmp_path: Path) -> None:
roots = _project_roots(tmp_path)
entry_path = roots["product"] / "jobs" / "backfill.py"
entry_path.parent.mkdir()
entry_path.write_text("raise AssertionError('must not run')", encoding="utf-8")
context = _context()
adapter = LegacyCommandAdapter(
LegacyProjectRoots(roots),
base_env={"PATH": "injected-path", "GYXX_RUN_ID": "cannot-win"},
python_executable="portable-python",
)
command = adapter.build(_entry(), context=context)
assert command.argv == (
"portable-python",
str(entry_path.resolve()),
"--limit",
"25",
)
assert command.cwd == roots["product"].resolve()
assert command.env["PATH"] == "injected-path"
assert command.env["GYXX_WORKFLOW_ID"] == "product.backfill"
assert command.env["GYXX_RUN_ID"] == context.run_id
assert command.env["GYXX_BUSINESS_DATE"] == "2026-07-27"
assert command.env["GYXX_SHADOW"] == "true"
assert command.env["GYXX_LEGACY_PROJECT_ROOT"] == str(roots["product"].resolve())
def test_adapter_keeps_batch_entry_and_args_as_separate_argv(tmp_path: Path) -> None:
roots = _project_roots(tmp_path)
entry_path = roots["content"] / "tools" / "daily run.bat"
entry_path.parent.mkdir()
entry_path.write_text("@echo off", encoding="utf-8")
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
command = adapter.build(
_entry(
workflow_id="content.metrics.daily",
module="content_marketing",
source_project="content",
entry="tools/daily run.bat",
args=("value with spaces", "&not-a-shell-fragment"),
),
context=RunContext.create(
"content.metrics.daily",
"2026-07-27",
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
random_suffix="def456",
),
)
assert command.argv == (
str(entry_path.resolve()),
"value with spaces",
"&not-a-shell-fragment",
)
@pytest.mark.parametrize(
"unsafe_entry",
("../outside.py", "jobs/../../outside.py", "/outside.py", "C:/outside.py", "jobs\\outside.py"),
)
def test_adapter_rejects_unsafe_entry_paths(tmp_path: Path, unsafe_entry: str) -> None:
roots = _project_roots(tmp_path)
outside = tmp_path / "outside.py"
outside.write_text("pass", encoding="utf-8")
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
with pytest.raises(LegacyConfigurationError, match="relative|escape"):
adapter.build(_entry(entry=unsafe_entry), context=_context())
def test_adapter_rejects_symlink_escape_when_supported(tmp_path: Path) -> None:
roots = _project_roots(tmp_path)
outside = tmp_path / "outside.py"
outside.write_text("pass", encoding="utf-8")
link = roots["product"] / "jobs"
try:
link.symlink_to(tmp_path, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks are unavailable: {exc}")
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
with pytest.raises(LegacyConfigurationError, match="escape"):
adapter.build(_entry(entry="jobs/outside.py"), context=_context())
def test_adapter_rejects_unknown_project_context_mismatch_and_unavailable_entry(
tmp_path: Path,
) -> None:
roots = _project_roots(tmp_path)
adapter = LegacyCommandAdapter(LegacyProjectRoots(roots), base_env={})
with pytest.raises(LegacyConfigurationError, match="unknown legacy project"):
adapter.build(_entry(source_project="other"), context=_context())
with pytest.raises(LegacyConfigurationError, match="does not match"):
adapter.build(
_entry(),
context=RunContext.create(
"another.workflow",
"2026-07-27",
now=datetime(2026, 7, 27, tzinfo=timezone.utc),
random_suffix="ghi789",
),
)
with pytest.raises(LegacyConfigurationError, match="unavailable"):
adapter.build(_entry(trigger="unavailable"), context=_context())
def test_adapter_construction_never_starts_a_process(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
roots = _project_roots(tmp_path)
entry_path = roots["product"] / "jobs" / "backfill.py"
entry_path.parent.mkdir()
entry_path.write_text("pass", encoding="utf-8")
def fail_if_called(*args: object, **kwargs: object) -> None:
raise AssertionError("adapter construction must not start subprocesses")
monkeypatch.setattr(subprocess, "run", fail_if_called)
command = LegacyCommandAdapter(
LegacyProjectRoots(roots),
base_env={},
python_executable=sys.executable,
).build(_entry(), context=_context())
assert command.argv[0] == sys.executable