222 lines
7.4 KiB
Python
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", "¬-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
|