diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..e662f2c --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,53 @@ +name: Backend CI + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + push: + branches: + - main + paths: + - "backend/**" + - ".github/workflows/backend-ci.yml" + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + + defaults: + run: + working-directory: backend + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Compile Python sources + run: python -m compileall app tests + + - name: Run backend regression tests + run: python -m unittest discover -s tests -v diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..2ffeff7 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,126 @@ +import asyncio +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi import HTTPException +from pydantic import ValidationError + +from app.main import ( + RunRequest, + apply_edits, + github_headers, + health, + parse_json_object, + safe_branch_name, + safe_repo_name, +) + + +class UtilitySafetyTests(unittest.TestCase): + def test_safe_repo_name_normalizes_git_url(self) -> None: + self.assertEqual( + safe_repo_name("https://github.com/example/project-name.git"), + "project-name", + ) + + def test_safe_branch_name_normalizes_unsafe_input(self) -> None: + self.assertEqual( + safe_branch_name(" feat//unsafe branch "), + "feat/unsafe-branch", + ) + + def test_parse_json_object_accepts_fenced_json(self) -> None: + payload = parse_json_object('```json\n{"summary":"ok","plan":[]}\n```') + self.assertEqual(payload, {"summary": "ok", "plan": []}) + + def test_apply_edits_updates_exact_match(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + target = repo / "src" / "example.txt" + target.parent.mkdir(parents=True) + target.write_text("before\n", encoding="utf-8") + + ok, message = apply_edits( + repo, + { + "edits": [ + { + "path": "src/example.txt", + "old": "before", + "new": "after", + } + ] + }, + ) + + self.assertTrue(ok, message) + self.assertEqual(target.read_text(encoding="utf-8"), "after\n") + + def test_apply_edits_rejects_path_escape(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + ok, message = apply_edits( + repo, + { + "edits": [ + { + "path": "../escape.txt", + "old": "before", + "new": "after", + } + ] + }, + ) + + self.assertFalse(ok) + self.assertIn("escapes repository", message) + + def test_apply_edits_is_atomic_when_later_edit_is_invalid(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + first = repo / "first.txt" + second = repo / "second.txt" + first.write_text("alpha", encoding="utf-8") + second.write_text("beta", encoding="utf-8") + + ok, _ = apply_edits( + repo, + { + "edits": [ + {"path": "first.txt", "old": "alpha", "new": "changed"}, + {"path": "second.txt", "old": "missing", "new": "changed"}, + ] + }, + ) + + self.assertFalse(ok) + self.assertEqual(first.read_text(encoding="utf-8"), "alpha") + self.assertEqual(second.read_text(encoding="utf-8"), "beta") + + def test_github_headers_requires_token(self) -> None: + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(HTTPException) as context: + github_headers() + + self.assertEqual(context.exception.status_code, 400) + + def test_run_request_rejects_invalid_iteration_count(self) -> None: + with self.assertRaises(ValidationError): + RunRequest( + repo_url="https://example.test/repo.git", + task="fix", + max_iterations=0, + ) + + def test_health_contract(self) -> None: + self.assertEqual( + asyncio.run(health()), + {"status": "ok", "service": "PatchPilot", "version": "0.4.0"}, + ) + + +if __name__ == "__main__": + unittest.main()