Initial commit: GitHub Release Monitor CLI tool
CLI tool that tracks GitHub releases or tags from user-defined repositories and exposes them as a subscribable RSS 2.0 feed. Features: - Track releases or tags per repository - SQLite storage with automatic deduplication - RSS 2.0 feed with markdown-stripped release notes - Built-in HTTP server for local feed access - Rate-limit-aware GitHub API client - 64 passing tests
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""Tests for the GitHub API client."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from src.github import GitHubClient, AuthenticationError, NotFoundError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api():
|
||||
"""Enable respx mocking for test scope."""
|
||||
with respx.mock:
|
||||
yield respx
|
||||
|
||||
|
||||
class TestFetchReleases:
|
||||
def test_fetch_releases(self, mock_api):
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/releases").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{
|
||||
"tag_name": "v1.0.0",
|
||||
"name": "Release v1.0.0",
|
||||
"body": "# Changelog\n\n- Fixed bug",
|
||||
"published_at": "2025-01-15T10:30:00Z",
|
||||
"html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
|
||||
},
|
||||
{
|
||||
"tag_name": "v0.9.0",
|
||||
"name": None,
|
||||
"body": "",
|
||||
"published_at": "2025-01-10T08:00:00Z",
|
||||
"html_url": "https://github.com/owner/repo/releases/tag/v0.9.0",
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
client = GitHubClient(token="fake_token")
|
||||
releases = client.fetch_releases("owner/repo")
|
||||
client.close()
|
||||
|
||||
assert len(releases) == 2
|
||||
assert releases[0]["tag_name"] == "v1.0.0"
|
||||
assert releases[0]["title"] == "Release v1.0.0"
|
||||
assert releases[0]["body"] == "# Changelog\n\n- Fixed bug"
|
||||
assert releases[1]["title"] == "v0.9.0"
|
||||
assert releases[1]["body"] == ""
|
||||
|
||||
def test_fetch_releases_404(self, mock_api):
|
||||
mock_api.get("https://api.github.com/repos/no/such/releases").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
|
||||
client = GitHubClient(token="fake_token")
|
||||
with pytest.raises(NotFoundError):
|
||||
client.fetch_releases("no/such")
|
||||
client.close()
|
||||
|
||||
|
||||
class TestFetchTags:
|
||||
def test_fetch_tags(self, mock_api):
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"name": "v1.0.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v1.0.0"},
|
||||
{"name": "v0.9.0", "zipball_url": "https://api.github.com/repos/owner/repo/zipball/v0.9.0"},
|
||||
])
|
||||
)
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"object": {
|
||||
"type": "commit",
|
||||
"author": {"date": "2025-01-15T10:30:00Z"},
|
||||
}
|
||||
})
|
||||
)
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v0.9.0").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"object": {
|
||||
"type": "commit",
|
||||
"author": {"date": "2025-01-10T08:00:00Z"},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
client = GitHubClient(token="fake_token")
|
||||
tags = client.fetch_tags("owner/repo")
|
||||
client.close()
|
||||
|
||||
assert len(tags) == 2
|
||||
assert tags[0]["tag_name"] == "v1.0.0"
|
||||
assert tags[0]["title"] == "v1.0.0"
|
||||
assert tags[0]["body"] == ""
|
||||
assert tags[0]["published_at"] == "2025-01-15T10:30:00Z"
|
||||
assert tags[0]["html_url"] == "https://github.com/owner/repo/tags/v1.0.0"
|
||||
|
||||
def test_fetch_tags_with_tag_object(self, mock_api):
|
||||
"""When a tag points to a tag object, follow to the commit."""
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"name": "v2.0.0", "zipball_url": "https://api.github.com/..."},
|
||||
])
|
||||
)
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v2.0.0").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"object": {
|
||||
"type": "tag",
|
||||
"sha": "abc123",
|
||||
}
|
||||
})
|
||||
)
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/git/commits/abc123").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"author": {"date": "2025-02-01T12:00:00Z"}
|
||||
})
|
||||
)
|
||||
|
||||
client = GitHubClient(token="fake_token")
|
||||
tags = client.fetch_tags("owner/repo")
|
||||
client.close()
|
||||
|
||||
assert tags[0]["published_at"] == "2025-02-01T12:00:00Z"
|
||||
|
||||
def test_fetch_tags_fallback_date(self, mock_api):
|
||||
"""When git ref fails, use current time as fallback."""
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/tags").mock(
|
||||
return_value=httpx.Response(200, json=[
|
||||
{"name": "v1.0.0", "zipball_url": "https://api.github.com/..."},
|
||||
])
|
||||
)
|
||||
mock_api.get("https://api.github.com/repos/owner/repo/git/ref/tags/v1.0.0").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
|
||||
client = GitHubClient(token="fake_token")
|
||||
tags = client.fetch_tags("owner/repo")
|
||||
client.close()
|
||||
|
||||
assert tags[0]["published_at"] is not None
|
||||
|
||||
|
||||
class TestRepoExists:
|
||||
def test_repo_exists_true(self, mock_api):
|
||||
mock_api.get("https://api.github.com/repos/owner/repo").mock(
|
||||
return_value=httpx.Response(200, json={"full_name": "owner/repo"})
|
||||
)
|
||||
client = GitHubClient(token="fake_token")
|
||||
assert client.repo_exists("owner/repo") is True
|
||||
client.close()
|
||||
|
||||
def test_repo_exists_false(self, mock_api):
|
||||
mock_api.get("https://api.github.com/repos/no/such").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
client = GitHubClient(token="fake_token")
|
||||
assert client.repo_exists("no/such") is False
|
||||
client.close()
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
def test_invalid_token(self, mock_api):
|
||||
mock_api.get("https://api.github.com/rate_limit").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
client = GitHubClient(token="bad_token")
|
||||
with pytest.raises(AuthenticationError, match="401 Unauthorized"):
|
||||
client.validate_token()
|
||||
client.close()
|
||||
|
||||
def test_no_token_skips_validation(self):
|
||||
client = GitHubClient(token=None)
|
||||
client.validate_token()
|
||||
client.close()
|
||||
Reference in New Issue
Block a user