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", "¬-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", "¬-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