diff --git a/backend/app/config.py b/backend/app/config.py index 3d50024..eafdb86 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,7 +13,7 @@ class Settings(BaseSettings): aws_bucket: str = "covered" aws_upload_role_arn: str - redis_url: AnyUrl + redis_url: AnyUrl | None = None github_token: SecretStr diff --git a/backend/app/dependencies/redis_client.py b/backend/app/dependencies/redis_client.py index 6930228..a7a76f6 100644 --- a/backend/app/dependencies/redis_client.py +++ b/backend/app/dependencies/redis_client.py @@ -4,5 +4,5 @@ from redis.asyncio import Redis -async def get_redis_client(request: Request) -> Redis: - return cast(Redis, request.state.redis_connection) +async def get_redis_client(request: Request) -> Redis | None: + return cast(Redis | None, request.state.redis_connection) diff --git a/backend/app/main.py b/backend/app/main.py index ba70843..1c8afaf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,9 @@ async def lifespan(_: FastAPI): settings = get_settings() - redis_connection = Redis.from_url(str(settings.redis_url)) + redis_connection = None + if settings.redis_url is not None: + redis_connection = Redis.from_url(str(settings.redis_url)) async with ( AWSStorage( access_key_id=settings.aws_access_key_id, @@ -28,7 +30,8 @@ async def lifespan(_: FastAPI): "gh_client": gh_client, "redis_connection": redis_connection, } - await redis_connection.aclose() + if redis_connection is not None: + await redis_connection.aclose() app = FastAPI(lifespan=lifespan, openapi_url=None) diff --git a/backend/app/routers/badge.py b/backend/app/routers/badge.py index 89eb53e..20b095f 100644 --- a/backend/app/routers/badge.py +++ b/backend/app/routers/badge.py @@ -125,12 +125,12 @@ async def badge( repo: str, gh_client: Annotated[GithubClient, Depends(get_github_client)], request: Request, - redis_client: Annotated[Redis, Depends(get_redis_client)], + redis_client: Annotated[Redis | None, Depends(get_redis_client)], ) -> Response: cache_key = BADGE_CACHE_KEY.format(org=org, repo=repo) try: - cached = await redis_client.get(cache_key) + cached = await redis_client.get(cache_key) if redis_client else None except RedisError as e: print(f"Error accessing cache: {e}") cached = None @@ -150,11 +150,12 @@ async def badge( ) try: - await redis_client.set( - cache_key, - svg.encode("utf-8"), - ex=60, - ) + if redis_client: + await redis_client.set( + cache_key, + svg.encode("utf-8"), + ex=60, + ) except RedisError as e: print(f"Error caching data: {e}") diff --git a/backend/app/routers/coverage.py b/backend/app/routers/coverage.py index 6aefcfa..a67af61 100644 --- a/backend/app/routers/coverage.py +++ b/backend/app/routers/coverage.py @@ -31,11 +31,12 @@ async def invalidate_cache( repo_owner: str, repo_name: str, - redis_client: Annotated[Redis, Depends(get_redis_client)], + redis_client: Annotated[Redis | None, Depends(get_redis_client)], ): cache_key = BADGE_CACHE_KEY.format(org=repo_owner, repo=repo_name) try: - await redis_client.delete(cache_key) + if redis_client: + await redis_client.delete(cache_key) except RedisError as e: print(f"Error invalidating cache for {repo_owner}/{repo_name}: {e}") raise HTTPException(status_code=500, detail="Failed to invalidate cache") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4691794..e325e3d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,6 +1,7 @@ import os from collections.abc import Iterator from contextlib import asynccontextmanager +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -91,13 +92,25 @@ def client(_patch_aiobotocore: None) -> Iterator[TestClient]: yield tc -@pytest.fixture(autouse=True) +@pytest.fixture(name="override_redis", autouse=True) def _override_redis(mock_redis: AsyncMock) -> Iterator[None]: app.dependency_overrides[get_redis_client] = lambda: mock_redis yield app.dependency_overrides.clear() +@pytest.fixture +def disable_redis( + override_redis: Any, monkeypatch: pytest.MonkeyPatch +) -> Iterator[None]: + """Disable Redis for a test.""" + old_dep = app.dependency_overrides.get(get_redis_client) + app.dependency_overrides[get_redis_client] = lambda: None + yield + if old_dep is not None: + app.dependency_overrides[get_redis_client] = old_dep + + @pytest.fixture(autouse=True, scope="session") def set_stamina_testing(): stamina.set_testing(True, attempts=10, cap=True) diff --git a/backend/tests/test_badge.py b/backend/tests/test_badge.py index d3ded20..9c6e7c1 100644 --- a/backend/tests/test_badge.py +++ b/backend/tests/test_badge.py @@ -10,6 +10,7 @@ pytestmark = pytest.mark.respx(base_url="https://api.github.com") + def get_commit(sha: str | None = None, skip_ci: bool = False) -> dict[str, Any]: if sha is None: sha = uuid.uuid4().hex @@ -420,6 +421,27 @@ def test_dont_write_cache_on_github_api_failure( assert not mock_redis.set.called # Don't cache failed responses + def test_get_badge_without_redis( + self, + client: TestClient, + respx_mock: respx.MockRouter, + mock_redis: AsyncMock, + disable_redis: None, + ): + # Test that the badge endpoint works even if Redis is not configured + commit = get_commit() + respx_mock.get("/repos/owner/repo/commits").respond(json=[commit]) + respx_mock.get(f"/repos/owner/repo/statuses/{commit['sha']}").respond( + json=[COVERAGE_STATUS] + ) + + resp = client.get("/badge/owner/repo.svg") + + assert resp.status_code == 200 + assert "coverage: 87%" in resp.text + mock_redis.get.assert_not_called() + mock_redis.set.assert_not_called() + class TestBadgeRedirect: def test_redirects_to_target_url( diff --git a/backend/tests/test_invalidate_cache.py b/backend/tests/test_invalidate_cache.py index 3d1c02a..9f181eb 100644 --- a/backend/tests/test_invalidate_cache.py +++ b/backend/tests/test_invalidate_cache.py @@ -72,3 +72,18 @@ def test_invalidates_cache_key_per_repo( ) mock_redis.delete.assert_awaited_once_with("cache:badge:some-org:some-repo") + + def test_no_redis_configured_does_not_error( + self, + client: TestClient, + mock_redis: AsyncMock, + disable_redis: None, + api_key: str, + ): + resp = client.post( + "/coverage/invalidate-cache/owner/repo/", + headers={"token": api_key}, + ) + + assert resp.status_code == 200 + mock_redis.delete.assert_not_called()