"""Tests for /api/ralph/* endpoints (dashboard live). Acoperă: - /api/ralph/status: list cards cu state + count + fetchedAt - /api/ralph//log: tail progress.txt - /api/ralph//prd: full prd.json - _ralph_validate_slug: path traversal protection - corrupt prd.json: graceful error (status='error', not 500) """ from __future__ import annotations import json import sys from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] DASH = PROJECT_ROOT / "dashboard" if str(DASH) not in sys.path: sys.path.insert(0, str(DASH)) @pytest.fixture(scope="module") def ralph_module(): from handlers import ralph as _r # type: ignore return _r @pytest.fixture def handler(ralph_module, tmp_path, monkeypatch): """Build a stubbed handler with a temp WORKSPACE_DIR.""" import constants # type: ignore # Re-route WORKSPACE_DIR la tmp pentru izolare monkeypatch.setattr(constants, "WORKSPACE_DIR", tmp_path) class _Stub(ralph_module.RalphHandlers): def __init__(self): self.captured = None self.captured_code = None self.path = "/api/ralph/status" def send_json(self, data, code=200): self.captured = data self.captured_code = code def send_error(self, code): self.captured = {"error_code": code} self.captured_code = code return _Stub() def _make_ralph_project(workspace: Path, slug: str, stories: list, progress: str = "init"): """Create a fake ralph project under workspace//scripts/ralph/.""" ralph_dir = workspace / slug / "scripts" / "ralph" ralph_dir.mkdir(parents=True, exist_ok=True) (ralph_dir / "prd.json").write_text(json.dumps({ "projectName": slug, "branchName": f"ralph/{slug}", "userStories": stories, }), encoding="utf-8") (ralph_dir / "progress.txt").write_text(progress, encoding="utf-8") return ralph_dir # ── /api/ralph/status ────────────────────────────────────────── class TestStatus: def test_empty_workspace_returns_empty(self, handler): handler.handle_ralph_status() assert handler.captured_code == 200 assert handler.captured["projects"] == [] assert "fetchedAt" in handler.captured def test_status_skips_non_ralph_projects(self, handler, tmp_path): # Create a project WITHOUT scripts/ralph (tmp_path / "regular-proj").mkdir() handler.handle_ralph_status() assert handler.captured["projects"] == [] def test_status_lists_ralph_projects(self, handler, tmp_path): _make_ralph_project(tmp_path, "proj-a", [ {"id": "US-001", "title": "a", "priority": 10, "passes": True, "failed": False, "blocked": False, "retries": 0, "tags": []}, {"id": "US-002", "title": "b", "priority": 20, "passes": False, "failed": False, "blocked": False, "retries": 0, "tags": ["ui"]}, ]) handler.handle_ralph_status() projects = handler.captured["projects"] assert len(projects) == 1 p = projects[0] assert p["slug"] == "proj-a" assert p["storiesTotal"] == 2 assert p["storiesComplete"] == 1 assert p["storiesFailed"] == 0 assert p["storiesBlocked"] == 0 assert p["status"] == "idle" # not running (no .ralph.pid) def test_status_corrupt_prd_returns_error_not_500(self, handler, tmp_path): ralph_dir = tmp_path / "broken" / "scripts" / "ralph" ralph_dir.mkdir(parents=True) (ralph_dir / "prd.json").write_text("{not valid json", encoding="utf-8") handler.handle_ralph_status() assert handler.captured_code == 200 assert any(p.get("status") == "error" for p in handler.captured["projects"]) def test_status_count_matches_projects(self, handler, tmp_path): _make_ralph_project(tmp_path, "p1", []) _make_ralph_project(tmp_path, "p2", []) handler.handle_ralph_status() assert handler.captured["count"] == 2 def test_complete_status_when_all_pass(self, handler, tmp_path): _make_ralph_project(tmp_path, "donezo", [ {"id": "US-001", "passes": True, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "x", "priority": 10}, ]) handler.handle_ralph_status() p = handler.captured["projects"][0] assert p["status"] == "complete" def test_failed_status_propagation(self, handler, tmp_path): _make_ralph_project(tmp_path, "broken-proj", [ {"id": "US-001", "passes": False, "failed": True, "blocked": False, "retries": 3, "tags": [], "title": "x", "priority": 10, "failureReason": "max_retries"}, ]) handler.handle_ralph_status() p = handler.captured["projects"][0] assert p["status"] == "failed" assert p["storiesFailed"] == 1 # ── /api/ralph//log ────────────────────────────────────── class TestLog: def test_log_returns_progress_lines(self, handler, tmp_path): _make_ralph_project(tmp_path, "p1", [], progress="line1\nline2\nline3") handler.path = "/api/ralph/p1/log" handler.handle_ralph_log("p1") assert handler.captured_code == 200 assert handler.captured["lines"] == ["line1", "line2", "line3"] assert handler.captured["total"] == 3 def test_log_invalid_slug_400(self, handler): handler.handle_ralph_log("../etc/passwd") assert handler.captured_code == 400 def test_log_path_traversal_blocked(self, handler): handler.handle_ralph_log("..") assert handler.captured_code == 400 def test_log_missing_progress_returns_empty(self, handler, tmp_path): ralph_dir = tmp_path / "noprogress" / "scripts" / "ralph" ralph_dir.mkdir(parents=True) (ralph_dir / "prd.json").write_text("{}") # no progress.txt handler.path = "/api/ralph/noprogress/log" handler.handle_ralph_log("noprogress") assert handler.captured_code == 200 assert handler.captured["lines"] == [] # ── /api/ralph//prd ────────────────────────────────────── class TestPrd: def test_prd_returns_full_json(self, handler, tmp_path): stories = [{"id": "US-001", "passes": False, "title": "t", "priority": 10}] _make_ralph_project(tmp_path, "p1", stories) handler.handle_ralph_prd("p1") assert handler.captured_code == 200 assert handler.captured["projectName"] == "p1" assert len(handler.captured["userStories"]) == 1 def test_prd_404_when_missing(self, handler, tmp_path): (tmp_path / "ghost").mkdir() # exists, but no prd.json handler.handle_ralph_prd("ghost") assert handler.captured_code == 404 def test_prd_invalid_slug_400(self, handler): handler.handle_ralph_prd("/etc/passwd") assert handler.captured_code == 400 # ── /api/ralph/usage ──────────────────────────────────────────── class TestUsageEndpoint: def test_usage_empty_workspace(self, handler): handler.path = "/api/ralph/usage" handler.handle_ralph_usage() assert handler.captured_code == 200 assert handler.captured["today_runs"] == 0 assert handler.captured["total_runs"] == 0 assert handler.captured["by_project"] == {} def test_usage_aggregates_across_projects(self, handler, tmp_path): # Create two projects, each with usage.jsonl for slug, cost, ts in [("proj-a", 0.5, "2026-04-26T10:00:00+00:00"), ("proj-b", 0.3, "2026-04-26T11:00:00+00:00")]: ralph_dir = tmp_path / slug / "scripts" / "ralph" ralph_dir.mkdir(parents=True) (ralph_dir / "usage.jsonl").write_text( json.dumps({"slug": slug, "ts": ts, "total_cost_usd": cost, "input_tokens": 100, "output_tokens": 50, "cache_read": 0}) + "\n", encoding="utf-8", ) handler.path = "/api/ralph/usage?days=30" handler.handle_ralph_usage() assert handler.captured_code == 200 # Should have both projects assert "proj-a" in handler.captured["by_project"] assert "proj-b" in handler.captured["by_project"] assert handler.captured["total_runs"] == 2 assert handler.captured["window_runs"] == 2 def test_usage_invalid_days_falls_back(self, handler): handler.path = "/api/ralph/usage?days=abc" handler.handle_ralph_usage() assert handler.captured_code == 200 assert handler.captured["window_days"] == 7 def test_usage_handles_corrupt_jsonl(self, handler, tmp_path): # Project with corrupt usage.jsonl shouldn't 500 ralph_dir = tmp_path / "broken" / "scripts" / "ralph" ralph_dir.mkdir(parents=True) (ralph_dir / "usage.jsonl").write_text("not json\n", encoding="utf-8") handler.path = "/api/ralph/usage" handler.handle_ralph_usage() assert handler.captured_code == 200 # ── _ralph_validate_slug ─────────────────────────────────────── class TestValidateSlug: def test_valid_slug_returns_path(self, handler, tmp_path): (tmp_path / "good-slug").mkdir() result = handler._ralph_validate_slug("good-slug") assert result is not None assert result.name == "good-slug" def test_slash_rejected(self, handler): assert handler._ralph_validate_slug("a/b") is None def test_dotdot_rejected(self, handler): assert handler._ralph_validate_slug("..") is None def test_empty_rejected(self, handler): assert handler._ralph_validate_slug("") is None def test_nonexistent_returns_none(self, handler): assert handler._ralph_validate_slug("does-not-exist") is None def test_underscore_allowed(self, handler, tmp_path): (tmp_path / "snake_case_slug").mkdir() result = handler._ralph_validate_slug("snake_case_slug") assert result is not None def test_too_long_rejected(self, handler): assert handler._ralph_validate_slug("a" * 65) is None def test_special_chars_rejected(self, handler): # Punctuaţie / spaţii / shell metachars — toate respinse de regex for bad in ("a b", "a;b", "a$b", "a.b", "a&b", "a|b", "a%2E"): assert handler._ralph_validate_slug(bad) is None, bad def test_backslash_rejected(self, handler): assert handler._ralph_validate_slug("a\\b") is None # ── _ralph_collect_status / _ralph_signature (SSE helpers) ──── class TestCollectAndSignature: def test_collect_empty_when_no_workspace(self, handler): snap = handler._ralph_collect_status() assert snap == {"projects": [], "fetchedAt": snap["fetchedAt"], "count": 0} def test_collect_lists_projects(self, handler, tmp_path): _make_ralph_project(tmp_path, "proj-x", [ {"id": "US-001", "passes": True, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "x", "priority": 10}, ]) snap = handler._ralph_collect_status() assert snap["count"] == 1 assert snap["projects"][0]["slug"] == "proj-x" def test_signature_stable_when_unchanged(self, handler, tmp_path): _make_ralph_project(tmp_path, "p1", []) snap1 = handler._ralph_collect_status() snap2 = handler._ralph_collect_status() # fetchedAt diferă — semnătura ignoră asta intenţionat assert handler._ralph_signature(snap1) == handler._ralph_signature(snap2) def test_signature_changes_when_project_added(self, handler, tmp_path): _make_ralph_project(tmp_path, "p1", []) sig1 = handler._ralph_signature(handler._ralph_collect_status()) _make_ralph_project(tmp_path, "p2", []) sig2 = handler._ralph_signature(handler._ralph_collect_status()) assert sig1 != sig2 def test_signature_changes_when_passes_changes(self, handler, tmp_path): _make_ralph_project(tmp_path, "p1", [ {"id": "US-001", "passes": False, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "a", "priority": 10}, ]) sig1 = handler._ralph_signature(handler._ralph_collect_status()) # mutăm story la passes=True ralph_dir = tmp_path / "p1" / "scripts" / "ralph" prd = json.loads((ralph_dir / "prd.json").read_text()) prd["userStories"][0]["passes"] = True (ralph_dir / "prd.json").write_text(json.dumps(prd)) sig2 = handler._ralph_signature(handler._ralph_collect_status()) assert sig1 != sig2 # ── /api/ralph//rollback ───────────────────────────────── def _git(cmd: list[str], cwd): """Run a git subcommand for test setup; raise if it fails.""" import subprocess return subprocess.run( ["git"] + cmd, cwd=str(cwd), check=True, capture_output=True, text=True, ) def _init_repo_with_two_commits(project_dir): """Create a real git repo with two commits — needed for revert/reset tests.""" project_dir.mkdir(parents=True, exist_ok=True) _git(["init", "-q", "-b", "main"], project_dir) _git(["config", "user.email", "test@example.com"], project_dir) _git(["config", "user.name", "Test"], project_dir) _git(["config", "commit.gpgsign", "false"], project_dir) (project_dir / "README.md").write_text("first") _git(["add", "README.md"], project_dir) _git(["commit", "-q", "-m", "first"], project_dir) (project_dir / "feature.txt").write_text("second commit content") _git(["add", "feature.txt"], project_dir) _git(["commit", "-q", "-m", "second"], project_dir) class TestRollback: def test_invalid_slug_400(self, handler): handler.handle_ralph_rollback("../etc/passwd") assert handler.captured_code == 400 assert handler.captured["success"] is False def test_path_traversal_blocked(self, handler): handler.handle_ralph_rollback("..") assert handler.captured_code == 400 def test_not_a_git_repo_400(self, handler, tmp_path): # Project există dar nu e git repo _make_ralph_project(tmp_path, "no-git", []) handler.handle_ralph_rollback("no-git") assert handler.captured_code == 400 assert "not a git" in handler.captured["message"].lower() def test_revert_success_with_story_decrement(self, handler, tmp_path): slug = "revert-ok" _make_ralph_project(tmp_path, slug, [ {"id": "US-001", "passes": True, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "first", "priority": 10}, {"id": "US-002", "passes": True, "failed": False, "blocked": False, "retries": 1, "tags": [], "title": "second", "priority": 20}, {"id": "US-003", "passes": False, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "third", "priority": 30}, ]) _init_repo_with_two_commits(tmp_path / slug) head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip() handler.handle_ralph_rollback(slug) assert handler.captured_code == 200, handler.captured assert handler.captured["success"] is True assert handler.captured["reverted_commit"] == head assert handler.captured["method"] == "revert" # ultima story trecută (US-002) trebuie marcată incompletă assert handler.captured["story_reverted"] == "US-002" # Verify atomic write efect: prd.json reflectă passes=False pe US-002 prd = json.loads( (tmp_path / slug / "scripts" / "ralph" / "prd.json").read_text() ) assert prd["userStories"][1]["id"] == "US-002" assert prd["userStories"][1]["passes"] is False assert prd["userStories"][1]["retries"] == 0 # US-001 rămâne neatins assert prd["userStories"][0]["passes"] is True # Verify git history: HEAD should be a new revert commit (not the old HEAD) new_head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip() assert new_head != head def test_revert_with_no_passing_stories_succeeds_without_decrement(self, handler, tmp_path): slug = "no-stories" _make_ralph_project(tmp_path, slug, [ {"id": "US-001", "passes": False, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "a", "priority": 10}, ]) _init_repo_with_two_commits(tmp_path / slug) handler.handle_ralph_rollback(slug) assert handler.captured_code == 200 assert handler.captured["success"] is True # nimic de decrementat → story_reverted=None assert handler.captured["story_reverted"] is None def test_response_shape_contract(self, handler, tmp_path): """Răspunsul trebuie să aibă fix aceste keys ca să meargă în UI.""" slug = "shape" _make_ralph_project(tmp_path, slug, []) _init_repo_with_two_commits(tmp_path / slug) handler.handle_ralph_rollback(slug) for k in ("success", "message", "reverted_commit", "story_reverted"): assert k in handler.captured, f"missing key: {k}" def test_decrement_helper_atomic_write(self, handler, tmp_path): """_ralph_decrement_last_pass: temp file nu trebuie să rămână în filesystem.""" slug = "atomic" ralph_dir = _make_ralph_project(tmp_path, slug, [ {"id": "US-001", "passes": True, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "x", "priority": 10}, ]) result = handler._ralph_decrement_last_pass(tmp_path / slug) assert result == "US-001" # tmp file curăţat assert not (ralph_dir / "prd.json.tmp").exists() # passes=False persistat prd = json.loads((ralph_dir / "prd.json").read_text()) assert prd["userStories"][0]["passes"] is False def test_decrement_helper_no_passing_returns_none(self, handler, tmp_path): slug = "nothing-to-revert" _make_ralph_project(tmp_path, slug, [ {"id": "US-001", "passes": False, "failed": False, "blocked": False, "retries": 0, "tags": [], "title": "x", "priority": 10}, ]) result = handler._ralph_decrement_last_pass(tmp_path / slug) assert result is None # ── api.py routing ───────────────────────────────────────────── class TestApiRouting: """Smoke test pentru ThreadingHTTPServer + dispatch /api/ralph/stream + rollback.""" def test_threading_http_server_in_use(self): import api # type: ignore # ThreadingHTTPServer este folosit pentru SSE non-blocking from http.server import ThreadingHTTPServer # Verify import doesn't reference deprecated HTTPServer at module level src = (PROJECT_ROOT / "dashboard" / "api.py").read_text() assert "ThreadingHTTPServer" in src def test_stream_route_dispatches_handler(self): """/api/ralph/stream trebuie să apeleze handle_ralph_stream.""" src = (PROJECT_ROOT / "dashboard" / "api.py").read_text() assert "/api/ralph/stream" in src assert "handle_ralph_stream" in src def test_rollback_route_dispatches_handler(self): src = (PROJECT_ROOT / "dashboard" / "api.py").read_text() assert "handle_ralph_rollback" in src