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
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ requires-python = ">=3.11"
dependencies = [
"requests>=2.34.2",
]

[project.optional-dependencies]
dev = ["pytest>=8"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Empty file added tests/__init__.py
Empty file.
81 changes: 81 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Shared fixtures for Issue-Finder tests."""

from __future__ import annotations

import time
from unittest import mock

import pytest
import requests


class MockResponse:
"""Build a mock requests.Response with the shape the code expects."""

def __init__(
self,
status_code: int = 200,
json_data: dict | None = None,
headers: dict[str, str] | None = None,
text: str = "",
):
self.status_code = status_code
self._json_data = json_data
self.headers = headers or {}
self.text = text

def json(self):
return self._json_data or {}


def make_search_result(
total_count: int = 0,
issue_ids: list[int] | None = None,
) -> dict:
"""Build a GitHub Search API result body."""
items = []
for iid in (issue_ids or []):
items.append(
{
"id": iid,
"title": f"Issue {iid}",
"html_url": f"https://github.com/owner/repo/issues/{iid}",
"repository_url": "https://api.github.com/repos/owner/repo",
"score": 1.0,
}
)
return {"total_count": total_count, "items": items, "incomplete_results": False}


@pytest.fixture
def success_response():
"""Return a MockResponse configured for a successful 200."""
return MockResponse(
status_code=200,
json_data=make_search_result(),
headers={
"X-RateLimit-Remaining": "30",
"X-RateLimit-Reset": str(int(time.time()) + 3600),
},
)


@pytest.fixture
def mock_requests(success_response):
"""Replace requests.get with a mock returning the success_response by default."""
with mock.patch.object(requests, "get") as mg:
mg.return_value = success_response
yield mg


@pytest.fixture
def mock_sleep():
"""Replace time.sleep with a no-op so tests don't actually wait."""
with mock.patch("time.sleep"):
yield


@pytest.fixture
def empty_env(monkeypatch):
"""Remove GITHUB_TOKEN so the unauthenticated path is exercised."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
107 changes: 107 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for the Issue-Finder GitHub API crawler."""

from __future__ import annotations

import time
import unittest.mock

import pytest
import requests

from main import fetch_page, wait_for_rate_limit, crawl_github_algorithm_issues

from tests.conftest import MockResponse, make_search_result


# ---------------------------------------------------------------------------
# wait_for_rate_limit
# ---------------------------------------------------------------------------

class TestWaitForRateLimit:
def test_uses_reset_ts(self, mock_sleep):
"""Sleeps until X-RateLimit-Reset when the header is present."""
future = int(time.time()) + 30
resp = MockResponse(headers={"X-RateLimit-Reset": str(future)})
wait_for_rate_limit(resp)
# 30s remaining + 2 buffer = 32
time.sleep.assert_any_call(32) # type: ignore[attr-defined]

def test_fallback_60s(self, mock_sleep):
"""Sleeps 60s when no rate-limit headers exist."""
resp = MockResponse(headers={})
wait_for_rate_limit(resp)
time.sleep.assert_any_call(60) # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# fetch_page
# ---------------------------------------------------------------------------

class TestFetchPage:
def test_success_returns_response(self, mock_sleep):
"""200 returns the response immediately."""
resp = MockResponse(
status_code=200,
json_data=make_search_result(total_count=1, issue_ids=[42]),
)
with unittest.mock.patch.object(requests, "get", return_value=resp):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"})
assert result is resp

def test_403_calls_wait_and_retries(self, mock_sleep):
"""403 triggers wait_for_rate_limit then retries."""
ok_resp = MockResponse(status_code=200, json_data=make_search_result())
rate_limited = MockResponse(status_code=403, headers={"X-RateLimit-Reset": str(int(time.time()) + 60)})
with unittest.mock.patch.object(requests, "get", side_effect=[rate_limited, ok_resp]):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"})
assert result is ok_resp

def test_422_returns_none(self, mock_sleep):
"""422 (query rejected) returns None without retrying."""
with unittest.mock.patch.object(requests, "get", return_value=MockResponse(status_code=422)):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"})
assert result is None

def test_unexpected_status_returns_none(self, mock_sleep):
"""Non-200/403/422 returns None."""
with unittest.mock.patch.object(requests, "get", return_value=MockResponse(status_code=500)):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"})
assert result is None

def test_timeout_retries(self, mock_sleep):
"""requests.exceptions.Timeout is caught and retried."""
ok_resp = MockResponse(status_code=200, json_data=make_search_result())
side_effects = [requests.exceptions.Timeout("timed out"), ok_resp]
with unittest.mock.patch.object(requests, "get", side_effect=side_effects):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"})
assert result is ok_resp

def test_max_retries_exceeded(self, mock_sleep):
"""Returns None after exhausting all retries."""
with unittest.mock.patch.object(
requests, "get", side_effect=requests.exceptions.ConnectionError("down")
):
result = fetch_page("https://api.github.com/search/issues", {}, {"q": "test"}, retries=2)
assert result is None


# ---------------------------------------------------------------------------
# crawl_github_algorithm_issues (integration-light)
# ---------------------------------------------------------------------------

class TestCrawl:
def test_produces_output_file(self, tmp_path, monkeypatch, mock_sleep):
"""Writes results.md with expected headers."""
import main as m
monkeypatch.setattr(m, "OUTPUT_FILE", str(tmp_path / "results.md"))

resp = MockResponse(
status_code=200,
json_data=make_search_result(total_count=1, issue_ids=[1001]),
)
with unittest.mock.patch.object(requests, "get", return_value=resp):
crawl_github_algorithm_issues()

content = (tmp_path / "results.md").read_text(encoding="utf-8")
assert "GitHub Algorithm & Architecture Issues" in content
assert "Issue 1001" in content