Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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()
Loading