Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
10adfe2
test: improve cache decorator coverage to 100%
EclipseAditya Oct 31, 2025
87f450a
test: improve api_key mutation coverage to 100%
EclipseAditya Oct 31, 2025
e6ac712
test: improve chapter endpoint coverage to 100%
EclipseAditya Oct 31, 2025
31c4af8
test: improve committee endpoint coverage to 100%
EclipseAditya Oct 31, 2025
d2e2ae1
test: improve event endpoint coverage to 100%
EclipseAditya Oct 31, 2025
4aa9cb4
test: improve issue endpoint coverage to 100%
EclipseAditya Oct 31, 2025
394e4e9
test: improve label endpoint coverage to 100%
EclipseAditya Oct 31, 2025
81459c6
test: improve member endpoint coverage to 100%
EclipseAditya Oct 31, 2025
32d310d
test: improve milestone endpoint coverage to 100%
EclipseAditya Nov 1, 2025
4d91bab
test: improve organization endpoint coverage to 100%
EclipseAditya Nov 1, 2025
6de85f2
test: add comprehensive coverage for CustomPagination utility
EclipseAditya Nov 1, 2025
1689530
test: improve project endpoint coverage to 100%
EclipseAditya Nov 1, 2025
9c0c191
test: add sponsor endpoint tests and fix CodeRabbit issues
EclipseAditya Nov 1, 2025
593395f
test: improve repository endpoint coverage to 100%
EclipseAditya Nov 1, 2025
c3ced64
test: improve release endpoint coverage to 100%
EclipseAditya Nov 1, 2025
951e409
fix check
EclipseAditya Nov 1, 2025
8c10abe
Merge branch 'main' into feature/improve-api-test-coverage
EclipseAditya Nov 2, 2025
2322d40
addressed bugs found during test coverage improvements
EclipseAditya Nov 2, 2025
f8912b3
address bugs and inconsistencies in REST API v0 endpoints
EclipseAditya Nov 2, 2025
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
4 changes: 3 additions & 1 deletion backend/apps/api/rest/v0/chapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ def get_chapter(
"""Get chapter."""
if chapter := ChapterModel.active_chapters.filter(
key__iexact=(
chapter_id if chapter_id.startswith("www-chapter-") else f"www-chapter-{chapter_id}"
chapter_id
if chapter_id.lower().startswith("www-chapter-")
else f"www-chapter-{chapter_id}"
)
).first():
return chapter
Expand Down
6 changes: 3 additions & 3 deletions backend/apps/api/rest/v0/committee.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,16 @@ def list_committees(
summary="Get committee",
)
@decorate_view(cache_response())
def get_chapter(
def get_committee(
request: HttpRequest,
committee_id: str = Path(example="project"),
) -> CommitteeDetail | CommitteeError:
"""Get chapter."""
"""Get committee."""
if committee := CommitteeModel.active_committees.filter(
is_active=True,
key__iexact=(
committee_id
if committee_id.startswith("www-committee-")
if committee_id.lower().startswith("www-committee-")
else f"www-committee-{committee_id}"
),
).first():
Expand Down
5 changes: 4 additions & 1 deletion backend/apps/api/rest/v0/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ def list_events(
),
) -> list[Event]:
"""Get all events."""
return EventModel.objects.order_by(ordering or "-start_date", "-end_date")
if ordering and ordering.lstrip("-") == "end_date":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we want to rely on user provided ordering here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the special logic for end_date because I thought when two events end on the same date, sorting by start_date seemed a bit more meaningful (longer events come first).
e.g. if Event A runs June 1-15 and Event B runs June 10-15, ordering by end_date, start_date would show A first.
But I am a bit in the middle whether we should add semantic logic or keeping it simple? would like a suggestion here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @arkid15r , I’d appreciate your input on this whenever you have a moment.

secondary = "-start_date" if ordering.startswith("-") else "start_date"
return EventModel.objects.order_by(ordering, secondary, "id")
return EventModel.objects.order_by(ordering or "-start_date", "-end_date", "id")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use event name for tie break.



@router.get(
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/api/rest/v0/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ def list_issues(
if filters.state:
issues = issues.filter(state=filters.state)

return issues.order_by(ordering or "-created_at", "-updated_at")
if ordering:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reasoning behind this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used id so that pagination becomes deterministic. Also, the earlier version had a double ordering bug, if a user provides ordering=updated_at, this would result in order_by("updated_at", "-updated_at"), which is contradictory. same with other files too.

return issues.order_by(ordering, "id")
return issues.order_by("-created_at", "-updated_at", "id")


@router.get(
Expand Down
9 changes: 7 additions & 2 deletions backend/apps/api/rest/v0/milestone.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ class MilestoneFilter(FilterSchema):
def list_milestones(
request: HttpRequest,
filters: MilestoneFilter = Query(...),
ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = None,
ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query(
None,
description="Ordering field",
),
) -> list[Milestone]:
"""Get all milestones."""
milestones = MilestoneModel.objects.select_related("repository", "repository__organization")
Expand All @@ -91,7 +94,9 @@ def list_milestones(
if filters.state:
milestones = milestones.filter(state=filters.state)

return milestones.order_by(ordering or "-created_at", "-updated_at")
if ordering:
return milestones.order_by(ordering, "id")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why you change the user provided ordering params?

return milestones.order_by("-created_at", "-updated_at", "id")


@router.get(
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/api/rest/v0/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ def get_project(
"""Get project."""
if project := ProjectModel.active_projects.filter(
key__iexact=(
project_id if project_id.startswith("www-project-") else f"www-project-{project_id}"
project_id
if project_id.lower().startswith("www-project-")
else f"www-project-{project_id}"
)
).first():
return project
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/api/rest/v0/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ def list_release(
if filters.tag_name:
releases = releases.filter(tag_name=filters.tag_name)

return releases.order_by(ordering or "-published_at", "-created_at")
if ordering:
return releases.order_by(ordering, "id")
return releases.order_by("-published_at", "-created_at", "id")


@router.get(
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/api/rest/v0/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ def list_repository(
if filters.organization_id:
repositories = repositories.filter(organization__login__iexact=filters.organization_id)

return repositories.order_by(ordering or "-created_at", "-updated_at")
if ordering:
return repositories.order_by(ordering, "id")
return repositories.order_by("-created_at", "-updated_at", "id")


@router.get(
Expand Down
84 changes: 83 additions & 1 deletion backend/tests/apps/api/decorators/cache_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_get_request_returns_cached_response(self, mock_cache, mock_request):
mock_cache.set.assert_not_called()
view_func.assert_not_called()

@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE"])
@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "PATCH"])
@patch("apps.api.decorators.cache.cache")
def test_non_get_head_requests_not_cached(self, mock_cache, method, mock_request):
"""Test that non-GET/HEAD requests are not cached."""
Expand Down Expand Up @@ -106,3 +106,85 @@ def test_non_200_responses_not_cached(self, mock_cache, status_code, mock_reques
mock_cache.get.assert_called_once()
mock_cache.set.assert_not_called()
view_func.assert_called_once_with(mock_request)

@patch("apps.api.decorators.cache.settings")
@patch("apps.api.decorators.cache.cache")
def test_default_ttl_from_settings(self, mock_cache, mock_settings, mock_request):
"""Test that default TTL is used from settings when not specified."""
mock_settings.API_CACHE_TIME_SECONDS = 3600
mock_settings.API_CACHE_PREFIX = "test-prefix"
mock_cache.get.return_value = None
view_func = MagicMock(return_value=HttpResponse(status=HTTPStatus.OK))
decorated_view = cache_response()(view_func)

response = decorated_view(mock_request)

assert response.status_code == HTTPStatus.OK
mock_cache.set.assert_called_once()
call_args = mock_cache.set.call_args
assert call_args[1]["timeout"] == 3600
view_func.assert_called_once_with(mock_request)

@patch("apps.api.decorators.cache.settings")
@patch("apps.api.decorators.cache.cache")
def test_default_prefix_from_settings(self, mock_cache, mock_settings, mock_request):
"""Test that default prefix is used from settings when not specified."""
mock_settings.API_CACHE_TIME_SECONDS = 60
mock_settings.API_CACHE_PREFIX = "custom-api-prefix"
mock_cache.get.return_value = None
view_func = MagicMock(return_value=HttpResponse(status=HTTPStatus.OK))
decorated_view = cache_response()(view_func)

response = decorated_view(mock_request)

assert response.status_code == HTTPStatus.OK
mock_cache.get.assert_called_once()
cache_key = mock_cache.get.call_args[0][0]
assert cache_key == "custom-api-prefix:/api/test"
view_func.assert_called_once_with(mock_request)

@patch("apps.api.decorators.cache.cache")
def test_head_request_caches_response(self, mock_cache, mock_request):
"""Test that HEAD requests are cached like GET requests."""
mock_request.method = "HEAD"
mock_cache.get.return_value = None
view_func = MagicMock(return_value=HttpResponse(status=HTTPStatus.OK))
decorated_view = cache_response(ttl=60)(view_func)

response = decorated_view(mock_request)

assert response.status_code == HTTPStatus.OK
mock_cache.get.assert_called_once()
mock_cache.set.assert_called_once()
view_func.assert_called_once_with(mock_request)

@patch("apps.api.decorators.cache.cache")
def test_head_request_returns_cached_response(self, mock_cache, mock_request):
"""Test that HEAD requests return cached responses."""
mock_request.method = "HEAD"
cached_response = HttpResponse(status=HTTPStatus.OK, content=b"cached")
mock_cache.get.return_value = cached_response
view_func = MagicMock()
decorated_view = cache_response(ttl=60)(view_func)

response = decorated_view(mock_request)

assert response == cached_response
mock_cache.get.assert_called_once()
mock_cache.set.assert_not_called()
view_func.assert_not_called()

@patch("apps.api.decorators.cache.cache")
def test_custom_prefix_parameter(self, mock_cache, mock_request):
"""Test that custom prefix parameter is used correctly."""
mock_cache.get.return_value = None
view_func = MagicMock(return_value=HttpResponse(status=HTTPStatus.OK))
decorated_view = cache_response(ttl=60, prefix="my-custom-prefix")(view_func)

response = decorated_view(mock_request)

assert response.status_code == HTTPStatus.OK
mock_cache.get.assert_called_once()
cache_key = mock_cache.get.call_args[0][0]
assert cache_key == "my-custom-prefix:/api/test"
view_func.assert_called_once_with(mock_request)
64 changes: 63 additions & 1 deletion backend/tests/apps/api/internal/mutations/api_key_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,67 @@ def api_key_mutations(self) -> ApiKeyMutations:
"""Pytest fixture to return an instance of the mutation class."""
return ApiKeyMutations()

def test_create_api_key_empty_name(self, api_key_mutations):
"""Test creating an API key with an empty name."""
info = mock_info()
name = ""
expires_at = timezone.now() + timedelta(days=30)

result = api_key_mutations.create_api_key(info, name=name, expires_at=expires_at)

assert isinstance(result, CreateApiKeyResult)
assert not result.ok
assert result.code == "INVALID_NAME"
assert result.message == "Name is required"
assert result.api_key is None
assert result.raw_key is None

def test_create_api_key_whitespace_name(self, api_key_mutations):
"""Test creating an API key with only whitespace in the name."""
info = mock_info()
name = " "
expires_at = timezone.now() + timedelta(days=30)

result = api_key_mutations.create_api_key(info, name=name, expires_at=expires_at)

assert isinstance(result, CreateApiKeyResult)
assert not result.ok
assert result.code == "INVALID_NAME"
assert result.message == "Name is required"
assert result.api_key is None
assert result.raw_key is None

@patch("apps.api.internal.mutations.api_key.MAX_WORD_LENGTH", 10)
def test_create_api_key_name_too_long(self, api_key_mutations):
"""Test creating an API key with a name exceeding the maximum length."""
info = mock_info()
name = "a" * 11
expires_at = timezone.now() + timedelta(days=30)

result = api_key_mutations.create_api_key(info, name=name, expires_at=expires_at)

assert isinstance(result, CreateApiKeyResult)
assert not result.ok
assert result.code == "INVALID_NAME"
assert result.message == "Name too long"
assert result.api_key is None
assert result.raw_key is None

def test_create_api_key_expires_in_past(self, api_key_mutations):
"""Test creating an API key with an expiry date in the past."""
info = mock_info()
name = "My Key"
expires_at = timezone.now() - timedelta(days=1)

result = api_key_mutations.create_api_key(info, name=name, expires_at=expires_at)

assert isinstance(result, CreateApiKeyResult)
assert not result.ok
assert result.code == "INVALID_DATE"
assert result.message == "Expiry date must be in future"
assert result.api_key is None
assert result.raw_key is None

@patch("apps.api.internal.mutations.api_key.ApiKey.create")
def test_create_api_key_success(self, mock_api_key_create, api_key_mutations):
"""Test the successful creation of an API key."""
Expand Down Expand Up @@ -82,12 +143,13 @@ def test_create_api_key_integrity_error(
):
"""Test the mutation's behavior when an IntegrityError is raised."""
info = mock_info()
user = info.context.request.user
name = "A key that causes a DB error"
expires_at = timezone.now() + timedelta(days=30)

result = api_key_mutations.create_api_key(info, name=name, expires_at=expires_at)

mock_api_key_create.assert_called_once()
mock_api_key_create.assert_called_once_with(user=user, name=name, expires_at=expires_at)
mock_logger.warning.assert_called_once()

assert isinstance(result, CreateApiKeyResult)
Expand Down
Loading