mirror of
https://github.com/codeflash-ai/codeflash-agent.git
synced 2026-05-04 18:25:19 +00:00
Both packages had tests/__init__.py, creating competing `tests` packages under --import-mode=importlib. Remove both __init__.py files and change github-app imports from `from tests.helpers` to `from helpers` via sys.path insertion in conftest.py.
317 lines
9.4 KiB
Python
317 lines
9.4 KiB
Python
"""Tests for the FastAPI webhook endpoint and dispatch handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from helpers import sign_payload
|
|
|
|
|
|
async def test_health_check(async_client):
|
|
resp = await async_client.get("/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ok"}
|
|
|
|
|
|
async def test_webhook_invalid_signature(async_client, pr_payload):
|
|
body = json.dumps(pr_payload).encode()
|
|
resp = await async_client.post(
|
|
"/webhook",
|
|
content=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Event": "pull_request",
|
|
"X-Hub-Signature-256": "sha256=invalid",
|
|
"X-GitHub-Delivery": "delivery-1",
|
|
},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_webhook_unknown_event(async_client):
|
|
payload = {"action": "test"}
|
|
body = json.dumps(payload).encode()
|
|
resp = await async_client.post(
|
|
"/webhook",
|
|
content=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Event": "unknown_event",
|
|
"X-Hub-Signature-256": sign_payload(body),
|
|
"X-GitHub-Delivery": "delivery-2",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ignored"
|
|
|
|
|
|
async def test_webhook_accepted(async_client, issue_payload, monkeypatch):
|
|
dispatched = []
|
|
|
|
async def fake_dispatch(payload, **kwargs):
|
|
dispatched.append(payload["action"])
|
|
|
|
monkeypatch.setattr(
|
|
"github_app.app.EVENT_HANDLERS",
|
|
{"issues": fake_dispatch},
|
|
)
|
|
|
|
body = json.dumps(issue_payload).encode()
|
|
resp = await async_client.post(
|
|
"/webhook",
|
|
content=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Event": "issues",
|
|
"X-Hub-Signature-256": sign_payload(body),
|
|
"X-GitHub-Delivery": "delivery-3",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "accepted"
|
|
|
|
await asyncio.sleep(0.05)
|
|
assert dispatched == ["opened"]
|
|
|
|
|
|
async def test_webhook_task_tracking(async_client, issue_payload, monkeypatch):
|
|
"""Background tasks are tracked in running_tasks and cleaned up."""
|
|
from github_app.app import app
|
|
|
|
gate = asyncio.Event()
|
|
|
|
async def slow_handler(payload, **kwargs):
|
|
await gate.wait()
|
|
|
|
monkeypatch.setattr(
|
|
"github_app.app.EVENT_HANDLERS",
|
|
{"issues": slow_handler},
|
|
)
|
|
|
|
body = json.dumps(issue_payload).encode()
|
|
await async_client.post(
|
|
"/webhook",
|
|
content=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Event": "issues",
|
|
"X-Hub-Signature-256": sign_payload(body),
|
|
"X-GitHub-Delivery": "delivery-4",
|
|
},
|
|
)
|
|
|
|
await asyncio.sleep(0.01)
|
|
assert len(app.state.running_tasks) == 1
|
|
|
|
gate.set()
|
|
await asyncio.sleep(0.05)
|
|
assert len(app.state.running_tasks) == 0
|
|
|
|
|
|
async def test_webhook_duplicate_delivery(async_client, issue_payload, monkeypatch):
|
|
"""Duplicate delivery IDs are detected and skipped."""
|
|
dispatched = []
|
|
|
|
async def fake_dispatch(payload, **kwargs):
|
|
dispatched.append(payload["action"])
|
|
|
|
monkeypatch.setattr(
|
|
"github_app.app.EVENT_HANDLERS",
|
|
{"issues": fake_dispatch},
|
|
)
|
|
|
|
body = json.dumps(issue_payload).encode()
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Event": "issues",
|
|
"X-Hub-Signature-256": sign_payload(body),
|
|
"X-GitHub-Delivery": "delivery-dup-test",
|
|
}
|
|
|
|
resp1 = await async_client.post("/webhook", content=body, headers=headers)
|
|
assert resp1.json()["status"] == "accepted"
|
|
|
|
await asyncio.sleep(0.05)
|
|
|
|
resp2 = await async_client.post("/webhook", content=body, headers=headers)
|
|
assert resp2.json()["status"] == "duplicate"
|
|
assert resp2.json()["delivery"] == "delivery-dup-test"
|
|
|
|
assert len(dispatched) == 1
|
|
|
|
|
|
async def test_dispatch_issues_writes_context(mock_config, issue_payload):
|
|
"""dispatch_issues writes ci-context.json and calls run_agent."""
|
|
from github_app.app import dispatch_issues
|
|
|
|
http_client = AsyncMock()
|
|
|
|
with (
|
|
patch("github_app.app.get_installation_token", return_value="tok"),
|
|
patch(
|
|
"github_app.app.clone_repo",
|
|
return_value=Path("/tmp/repo"),
|
|
),
|
|
patch("github_app.app._write_ci_context") as write_ctx_mock,
|
|
patch("github_app.app.run_agent") as run_agent_mock,
|
|
):
|
|
run_agent_mock.return_value = "done"
|
|
await dispatch_issues(
|
|
issue_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
write_ctx_mock.assert_called_once()
|
|
ctx_arg = write_ctx_mock.call_args.args[1]
|
|
assert ctx_arg["event_type"] == "issues"
|
|
assert ctx_arg["action"] == "opened"
|
|
assert ctx_arg["number"] == 7
|
|
assert ctx_arg["owner"] == "test-owner"
|
|
assert ctx_arg["repo"] == "test-repo"
|
|
|
|
run_agent_mock.assert_called_once_with(
|
|
mock_config,
|
|
Path("/tmp/repo"),
|
|
"tok",
|
|
)
|
|
|
|
|
|
async def test_dispatch_pr_writes_context(mock_config, pr_payload):
|
|
"""dispatch_pr writes ci-context.json with PR fields."""
|
|
from github_app.app import dispatch_pr
|
|
|
|
http_client = AsyncMock()
|
|
|
|
with (
|
|
patch("github_app.app.get_installation_token", return_value="tok"),
|
|
patch(
|
|
"github_app.app.clone_repo",
|
|
return_value=Path("/tmp/repo"),
|
|
),
|
|
patch("github_app.app._write_ci_context") as write_ctx_mock,
|
|
patch("github_app.app.run_agent") as run_agent_mock,
|
|
):
|
|
run_agent_mock.return_value = "done"
|
|
await dispatch_pr(
|
|
pr_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
write_ctx_mock.assert_called_once()
|
|
ctx_arg = write_ctx_mock.call_args.args[1]
|
|
assert ctx_arg["event_type"] == "pull_request"
|
|
assert ctx_arg["action"] == "opened"
|
|
assert ctx_arg["number"] == 42
|
|
assert ctx_arg["base_ref"] == "main"
|
|
assert ctx_arg["head_ref"] == "feature-branch"
|
|
|
|
run_agent_mock.assert_called_once()
|
|
call_kwargs = run_agent_mock.call_args.kwargs
|
|
assert call_kwargs["agent"] == "codeflash-deep"
|
|
assert "CI run triggered by PR #42" in call_kwargs["prompt"]
|
|
assert "feature-branch" in call_kwargs["prompt"]
|
|
|
|
|
|
async def test_dispatch_push_writes_context(mock_config, push_payload):
|
|
"""dispatch_push writes ci-context.json with push fields."""
|
|
from github_app.app import dispatch_push
|
|
|
|
http_client = AsyncMock()
|
|
|
|
with (
|
|
patch("github_app.app.get_installation_token", return_value="tok"),
|
|
patch(
|
|
"github_app.app.clone_repo",
|
|
return_value=Path("/tmp/repo"),
|
|
),
|
|
patch("github_app.app._write_ci_context") as write_ctx_mock,
|
|
patch("github_app.app.run_agent") as run_agent_mock,
|
|
):
|
|
run_agent_mock.return_value = "done"
|
|
await dispatch_push(
|
|
push_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
write_ctx_mock.assert_called_once()
|
|
ctx_arg = write_ctx_mock.call_args.args[1]
|
|
assert ctx_arg["event_type"] == "push"
|
|
assert ctx_arg["action"] is None
|
|
assert ctx_arg["head_sha"] == "abc123"
|
|
assert ctx_arg["ref"] == "refs/heads/main"
|
|
|
|
run_agent_mock.assert_called_once()
|
|
|
|
|
|
async def test_dispatch_push_ignores_non_default_branch(mock_config, push_payload):
|
|
"""Push to a non-default branch is ignored."""
|
|
from github_app.app import dispatch_push
|
|
|
|
push_payload["ref"] = "refs/heads/feature-branch"
|
|
http_client = AsyncMock()
|
|
|
|
with (
|
|
patch("github_app.app.get_installation_token") as token_mock,
|
|
patch("github_app.app.run_agent") as run_agent_mock,
|
|
):
|
|
await dispatch_push(
|
|
push_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
token_mock.assert_not_called()
|
|
run_agent_mock.assert_not_called()
|
|
|
|
|
|
async def test_dispatch_issues_ignores_irrelevant_action(mock_config, issue_payload):
|
|
"""Actions other than opened/labeled are ignored."""
|
|
from github_app.app import dispatch_issues
|
|
|
|
issue_payload["action"] = "closed"
|
|
http_client = AsyncMock()
|
|
|
|
with patch("github_app.app.run_agent") as run_agent_mock:
|
|
await dispatch_issues(
|
|
issue_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
run_agent_mock.assert_not_called()
|
|
|
|
|
|
async def test_dispatch_pr_ignores_irrelevant_action(mock_config, pr_payload):
|
|
"""Actions other than opened/synchronize are ignored."""
|
|
from github_app.app import dispatch_pr
|
|
|
|
pr_payload["action"] = "closed"
|
|
http_client = AsyncMock()
|
|
|
|
with patch("github_app.app.run_agent") as run_agent_mock:
|
|
await dispatch_pr(
|
|
pr_payload,
|
|
config=mock_config,
|
|
http_client=http_client,
|
|
)
|
|
|
|
run_agent_mock.assert_not_called()
|
|
|
|
|
|
async def test_write_ci_context(tmp_path):
|
|
"""_write_ci_context creates the .codeflash dir and writes JSON."""
|
|
from github_app.app import _write_ci_context
|
|
|
|
_write_ci_context(str(tmp_path), {"event_type": "issues", "number": 7})
|
|
|
|
ctx_file = tmp_path / ".codeflash" / "ci-context.json"
|
|
assert ctx_file.exists()
|
|
data = json.loads(ctx_file.read_text())
|
|
assert data["event_type"] == "issues"
|
|
assert data["number"] == 7
|