diff --git a/tests/test_ingest.py b/tests/test_ingest.py index dd9e17ea8..65edbde8d 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -1,9 +1,10 @@ """Tests for graphify.ingest.save_query_result""" from __future__ import annotations import re +import urllib.error from pathlib import Path import pytest -from graphify.ingest import save_query_result +from graphify.ingest import ingest, save_query_result def test_file_created(tmp_path): @@ -98,3 +99,88 @@ def test_no_outcome_means_no_outcome_section(tmp_path): def test_invalid_outcome_rejected(tmp_path): with pytest.raises(ValueError): save_query_result("q", "a", tmp_path / "memory", outcome="great") + + +def test_ingest_webpage_writes_markdown(tmp_path, monkeypatch): + target = tmp_path / "raw" + monkeypatch.setattr("graphify.ingest.validate_url", lambda _url: None) + monkeypatch.setattr( + "graphify.ingest._fetch_webpage", + lambda _url, _author, _contributor: ("# Example", "example.md"), + ) + + out = ingest("https://example.com/docs", target) + + assert out == target / "example.md" + assert out.read_text(encoding="utf-8") == "# Example" + + +def test_ingest_avoids_overwrite_with_counter(tmp_path, monkeypatch): + target = tmp_path / "raw" + target.mkdir(parents=True) + (target / "example.md").write_text("existing", encoding="utf-8") + monkeypatch.setattr("graphify.ingest.validate_url", lambda _url: None) + monkeypatch.setattr( + "graphify.ingest._fetch_webpage", + lambda _url, _author, _contributor: ("# New", "example.md"), + ) + + out = ingest("https://example.com/docs", target) + + assert out.name == "example_1.md" + assert out.read_text(encoding="utf-8") == "# New" + + +def test_ingest_pdf_path_uses_binary_downloader(tmp_path, monkeypatch): + target = tmp_path / "raw" + downloaded = target / "paper.pdf" + monkeypatch.setattr("graphify.ingest.validate_url", lambda _url: None) + monkeypatch.setattr("graphify.ingest._download_binary", lambda *_a, **_k: downloaded) + + out = ingest("https://example.com/paper.pdf", target) + + assert out == downloaded + + +def test_ingest_image_without_suffix_defaults_to_jpg(tmp_path, monkeypatch): + target = tmp_path / "raw" + monkeypatch.setattr("graphify.ingest.validate_url", lambda _url: None) + monkeypatch.setattr("graphify.ingest._detect_url_type", lambda _url: "image") + captured = {} + + def _fake_download(url: str, suffix: str, _target): + captured["url"] = url + captured["suffix"] = suffix + return target / "img.jpg" + + monkeypatch.setattr("graphify.ingest._download_binary", _fake_download) + + out = ingest("https://example.com/image", target) + + assert out.name == "img.jpg" + assert captured["suffix"] == ".jpg" + + +def test_ingest_invalid_url_is_wrapped(tmp_path, monkeypatch): + target = tmp_path / "raw" + + def _raise_invalid(_url: str) -> None: + raise ValueError("blocked") + + monkeypatch.setattr("graphify.ingest.validate_url", _raise_invalid) + + with pytest.raises(ValueError, match=r"^ingest: blocked$"): + ingest("javascript:alert(1)", target) + + +def test_ingest_fetch_errors_are_wrapped(tmp_path, monkeypatch): + target = tmp_path / "raw" + monkeypatch.setattr("graphify.ingest.validate_url", lambda _url: None) + + def _raise_fetch(_url, _author, _contributor): + raise urllib.error.URLError("timeout") + + monkeypatch.setattr("graphify.ingest._fetch_webpage", _raise_fetch) + + with pytest.raises(RuntimeError, match=r"^ingest: failed to fetch "): + ingest("https://example.com", target) diff --git a/tests/test_prs.py b/tests/test_prs.py index 4acc343e6..31aa51963 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -17,9 +17,11 @@ _path_match, build_community_labels, compute_pr_impact, + fetch_prs, fetch_pr_files, fetch_worktrees, format_prs_text, + attach_graph_impact, _detect_default_branch, ) @@ -331,6 +333,74 @@ def test_empty_pr_list(self): assert "(0 on wrong base, not shown)" in out +class TestFetchPrs: + def test_fetch_prs_parses_gh_payload(self): + updated_at = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat().replace( + "+00:00", "Z" + ) + raw = [ + { + "number": 7, + "title": "Fix auth race", + "headRefName": "fix-auth", + "baseRefName": "v8", + "author": {"login": "alice"}, + "isDraft": False, + "reviewDecision": "APPROVED", + "statusCheckRollup": [{"conclusion": "SUCCESS", "status": "COMPLETED"}], + "updatedAt": updated_at, + } + ] + with patch("graphify.prs._gh", return_value=raw): + prs = fetch_prs(base="v8") + assert len(prs) == 1 + pr = prs[0] + assert pr.number == 7 + assert pr.branch == "fix-auth" + assert pr.author == "alice" + assert pr.ci_status == "SUCCESS" + assert pr.expected_base == "v8" + assert pr.status == "APPROVED" + + def test_fetch_prs_raises_when_gh_unavailable(self): + with patch("graphify.prs._gh", return_value=None): + with pytest.raises(RuntimeError, match="gh CLI not found or not authenticated"): + fetch_prs() + + +class TestAttachGraphImpact: + def test_attach_graph_impact_populates_actionable_prs_only(self, tmp_path): + graph = { + "nodes": [ + {"id": "n1", "label": "AuthAPI", "source_file": "src/auth/api.py", "community": 2}, + {"id": "n2", "label": "AuthHelper", "source_file": "src/auth/api.py", "community": 2}, + {"id": "n3", "label": "Billing", "source_file": "src/billing/pay.py", "community": 7}, + ], + "edges": [], + } + graph_path = tmp_path / "graph.json" + graph_path.write_text(json.dumps(graph), encoding="utf-8") + + actionable = make_pr(number=1, base_branch="v8", expected_base="v8") + wrong_base = make_pr(number=2, base_branch="main", expected_base="v8") + prs = [actionable, wrong_base] + + def _fake_files(number: int, _repo=None) -> list[str]: + if number == 1: + return ["src/auth/api.py"] + raise AssertionError("wrong-base PR should not request diff files") + + with patch("graphify.prs.fetch_pr_files", side_effect=_fake_files): + labels = attach_graph_impact(prs, graph_path) + + assert labels[2] == ["AuthAPI", "AuthHelper"] + assert actionable.communities_touched == [2] + assert actionable.nodes_affected == 2 + assert actionable.files_changed == ["src/auth/api.py"] + assert wrong_base.communities_touched == [] + assert wrong_base.nodes_affected == 0 + + # ── _detect_default_branch ──────────────────────────────────────────────────── class TestDetectDefaultBranch: diff --git a/tests/test_serve.py b/tests/test_serve.py index fdaa2753c..1c85f31a1 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1,4 +1,5 @@ """Tests for serve.py - MCP graph query helpers (no mcp package required).""" +import asyncio import json import pytest import networkx as nx @@ -28,6 +29,7 @@ _cut_lines_to_budget, _load_graph, _community_header, + _ApiKeyMiddleware, _search_tokens, ) @@ -1386,3 +1388,64 @@ def test_subgraph_to_text_honors_valid_src_tgt_direction(): out = _subgraph_to_text(G, {"caller", "callee"}, [("callee", "caller")]) edge_line = next(l for l in out.splitlines() if l.startswith("EDGE")) assert "caller --calls" in edge_line and "--> callee" in edge_line + + +def _run_asgi(_app, scope: dict) -> tuple[list[dict], int]: + sent: list[dict] = [] + app_calls = 0 + + async def _receive() -> dict: + return {"type": "http.request", "body": b"", "more_body": False} + + async def _send(message: dict) -> None: + sent.append(message) + + async def _inner() -> int: + nonlocal app_calls + + async def _downstream(_scope, _receive, send): + nonlocal app_calls + app_calls += 1 + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + wrapped = _ApiKeyMiddleware(_downstream, api_key="s3cret") + await wrapped(scope, _receive, _send) + return app_calls + + calls = asyncio.run(_inner()) + return sent, calls + + +def test_api_key_middleware_rejects_missing_key(): + scope = {"type": "http", "headers": []} + sent, calls = _run_asgi(None, scope) + + assert calls == 0 + assert sent[0]["type"] == "http.response.start" + assert sent[0]["status"] == 401 + assert sent[1]["body"] == b'{"error": "unauthorized"}' + + +def test_api_key_middleware_accepts_x_api_key(): + scope = {"type": "http", "headers": [(b"x-api-key", b"s3cret")]} + sent, calls = _run_asgi(None, scope) + + assert calls == 1 + assert sent[0]["status"] == 204 + + +def test_api_key_middleware_accepts_case_insensitive_bearer(): + scope = {"type": "http", "headers": [(b"authorization", b"bEaReR s3cret")]} + sent, calls = _run_asgi(None, scope) + + assert calls == 1 + assert sent[0]["status"] == 204 + + +def test_api_key_middleware_skips_non_http_scopes(): + scope = {"type": "lifespan", "headers": []} + sent, calls = _run_asgi(None, scope) + + assert calls == 1 + assert sent[0]["status"] == 204