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
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions backend/app/dependencies/redis_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 5 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions backend/app/routers/badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")

Expand Down
5 changes: 3 additions & 2 deletions backend/app/routers/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
22 changes: 22 additions & 0 deletions backend/tests/test_badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions backend/tests/test_invalidate_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()