From 436deb104c3a26c51505f69b51413fcf9dbdc5bc Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 30 Jan 2026 19:09:36 +0000 Subject: [PATCH 1/8] feat: improve test coverage to 90% --- backend/apps/api/rest/v0/pagination.py | 6 - backend/pyproject.toml | 2 +- backend/tests/apps/ai/agent/agent_test.py | 96 ++++ backend/tests/apps/ai/agent/nodes_test.py | 141 +++++ backend/tests/apps/api/rest/v0/issue_test.py | 140 ++++- .../tests/apps/api/rest/v0/milestone_test.py | 140 ++++- .../tests/apps/api/rest/v0/pagination_test.py | 152 ++++++ .../tests/apps/api/rest/v0/release_test.py | 140 ++++- backend/tests/apps/common/index_test.py | 108 ++++ backend/tests/apps/core/models/__init__.py | 0 backend/tests/apps/core/models/prompt_test.py | 178 +++++++ backend/tests/apps/github/common_test.py | 172 +++++- .../apps/owasp/admin/entity_channel_test.py | 104 ++++ .../apps/owasp/admin/entity_member_test.py | 122 +++++ .../queries/project_health_metrics_test.py | 92 +++- .../api/internal/queries/project_test.py | 137 +++++ .../api/internal/queries/snapshot_test.py | 82 +++ .../owasp/api/internal/queries/stats_test.py | 62 +++ .../commands/process_snapshots_test.py | 82 ++- .../tests/apps/owasp/models/committee_test.py | 67 +++ .../apps/owasp/models/entity_member_test.py | 121 +++++ backend/tests/apps/owasp/models/event_test.py | 303 +++++++++++ .../apps/owasp/models/mixins/project_test.py | 293 +++++++++++ .../tests/apps/owasp/models/project_test.py | 331 ++++++++++++ .../commands/slack_sync_messages_test.py | 494 +++++++++++++++++- cspell/custom-dict.txt | 2 + 26 files changed, 3545 insertions(+), 22 deletions(-) create mode 100644 backend/tests/apps/ai/agent/agent_test.py create mode 100644 backend/tests/apps/api/rest/v0/pagination_test.py create mode 100644 backend/tests/apps/core/models/__init__.py create mode 100644 backend/tests/apps/core/models/prompt_test.py create mode 100644 backend/tests/apps/owasp/admin/entity_channel_test.py create mode 100644 backend/tests/apps/owasp/admin/entity_member_test.py create mode 100644 backend/tests/apps/owasp/api/internal/queries/snapshot_test.py create mode 100644 backend/tests/apps/owasp/api/internal/queries/stats_test.py create mode 100644 backend/tests/apps/owasp/models/entity_member_test.py create mode 100644 backend/tests/apps/owasp/models/mixins/project_test.py diff --git a/backend/apps/api/rest/v0/pagination.py b/backend/apps/api/rest/v0/pagination.py index c6c37fde73..8c70b657fe 100644 --- a/backend/apps/api/rest/v0/pagination.py +++ b/backend/apps/api/rest/v0/pagination.py @@ -32,20 +32,14 @@ def paginate_queryset(self, queryset, pagination: Input, **params): """Paginate the queryset and return standardized output.""" page = pagination.page page_size = pagination.page_size - - # Calculate pagination. total_count = queryset.count() - # Ensure total_pages is at least 1 for consistent metadata. total_pages = max(1, (total_count + page_size - 1) // page_size) - - # Validate that the requested page is within the valid range. if page > total_pages: message = f"Page {page} not found. Valid pages are 1 to {total_pages}." raise Http404(message) offset = (page - 1) * page_size - # Get the page items. items = list(queryset[offset : offset + page_size]) return { diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 962339fbd1..e1f302a780 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -141,7 +141,7 @@ DJANGO_CONFIGURATION = "Test" DJANGO_SETTINGS_MODULE = "settings.test" addopts = [ "--cov-config=pyproject.toml", - "--cov-fail-under=80", + "--cov-fail-under=90", "--cov-precision=2", "--cov-report=term-missing", "--cov-report=xml", diff --git a/backend/tests/apps/ai/agent/agent_test.py b/backend/tests/apps/ai/agent/agent_test.py new file mode 100644 index 0000000000..f078580d16 --- /dev/null +++ b/backend/tests/apps/ai/agent/agent_test.py @@ -0,0 +1,96 @@ +"""Tests for the AgenticRAGAgent class.""" + +from unittest.mock import MagicMock + +from apps.ai.agent.agent import AgenticRAGAgent + + +class TestAgenticRAGAgent: + """Tests for AgenticRAGAgent.""" + + target_module = "apps.ai.agent.agent" + + def test_init(self, mocker): + """Test AgenticRAGAgent initialization.""" + mock_nodes = mocker.patch(f"{self.target_module}.AgentNodes") + mock_state_graph = mocker.patch(f"{self.target_module}.StateGraph") + + mock_graph_instance = MagicMock() + mock_state_graph.return_value = mock_graph_instance + + agent = AgenticRAGAgent() + + mock_nodes.assert_called_once() + assert agent.nodes is not None + assert agent.graph is not None + + def test_run(self, mocker): + """Test the run method executes the RAG workflow.""" + mocker.patch(f"{self.target_module}.AgentNodes") + mock_state_graph = mocker.patch(f"{self.target_module}.StateGraph") + + mock_graph_instance = MagicMock() + mock_compiled_graph = MagicMock() + mock_state_graph.return_value = mock_graph_instance + mock_graph_instance.compile.return_value = mock_compiled_graph + + mock_compiled_graph.invoke.return_value = { + "answer": "Test answer", + "iteration": 2, + "evaluation": {"score": 0.9}, + "context_chunks": [{"text": "chunk1"}], + "history": ["step1", "step2"], + "extracted_metadata": {"key": "value"}, + } + + agent = AgenticRAGAgent() + result = agent.run("Test query") + + mock_compiled_graph.invoke.assert_called_once() + assert result["answer"] == "Test answer" + assert result["iterations"] == 2 + assert result["evaluation"] == {"score": 0.9} + assert result["context_chunks"] == [{"text": "chunk1"}] + assert result["history"] == ["step1", "step2"] + assert result["extracted_metadata"] == {"key": "value"} + + def test_run_with_empty_result(self, mocker): + """Test run method handles empty results gracefully.""" + mocker.patch(f"{self.target_module}.AgentNodes") + mock_state_graph = mocker.patch(f"{self.target_module}.StateGraph") + + mock_graph_instance = MagicMock() + mock_compiled_graph = MagicMock() + mock_state_graph.return_value = mock_graph_instance + mock_graph_instance.compile.return_value = mock_compiled_graph + + mock_compiled_graph.invoke.return_value = {} + + agent = AgenticRAGAgent() + result = agent.run("Test query") + + assert result["answer"] == "" + assert result["iterations"] == 0 + assert result["evaluation"] == {} + assert result["context_chunks"] == [] + assert result["history"] == [] + assert result["extracted_metadata"] == {} + + def test_build_graph(self, mocker): + """Test build_graph creates the correct state machine.""" + mocker.patch(f"{self.target_module}.AgentNodes") + mock_state_graph = mocker.patch(f"{self.target_module}.StateGraph") + mock_start = mocker.patch(f"{self.target_module}.START") + mocker.patch(f"{self.target_module}.END") + + mock_graph_instance = MagicMock() + mock_state_graph.return_value = mock_graph_instance + + AgenticRAGAgent() + + assert mock_graph_instance.add_node.call_count == 3 + mock_graph_instance.add_edge.assert_any_call(mock_start, "retrieve") + mock_graph_instance.add_edge.assert_any_call("retrieve", "generate") + mock_graph_instance.add_edge.assert_any_call("generate", "evaluate") + mock_graph_instance.add_conditional_edges.assert_called_once() + mock_graph_instance.compile.assert_called_once() diff --git a/backend/tests/apps/ai/agent/nodes_test.py b/backend/tests/apps/ai/agent/nodes_test.py index 4d5cc5210e..e6155606d3 100644 --- a/backend/tests/apps/ai/agent/nodes_test.py +++ b/backend/tests/apps/ai/agent/nodes_test.py @@ -1,5 +1,6 @@ import openai import pytest +from django.core.exceptions import ObjectDoesNotExist from apps.ai.agent.nodes import AgentNodes from apps.ai.common.constants import DEFAULT_CHUNKS_RETRIEVAL_LIMIT, DEFAULT_SIMILARITY_THRESHOLD @@ -79,6 +80,20 @@ def test_evaluate_requires_more_context(self, nodes, mocker): assert "context_chunks" in new_state assert new_state["evaluation"] == mock_eval + def test_evaluate_updates_history(self, nodes, mocker): + """Test that evaluation updates the last history entry.""" + state = { + "query": "test", + "answer": "good", + "history": [{"iteration": 1, "answer": "good"}], + } + mock_eval = {"requires_more_context": False, "feedback": None, "complete": True} + nodes.call_evaluator = mocker.Mock(return_value=mock_eval) + + new_state = nodes.evaluate(state) + + assert new_state["history"][-1]["evaluation"] == mock_eval + def test_evaluate_complete(self, nodes, mocker): state = {"query": "test", "answer": "good"} mock_eval = {"requires_more_context": False, "feedback": None, "complete": True} @@ -109,6 +124,82 @@ def test_filter_chunks_by_metadata(self, nodes): filtered = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) assert filtered[0]["text"] == "foo" + def test_filter_chunks_empty_list(self, nodes): + """Test filter_chunks_by_metadata returns empty list for empty input.""" + result = nodes.filter_chunks_by_metadata([], {"filters": {}}, limit=10) + assert result == [] + + def test_filter_chunks_no_filters(self, nodes): + """Test filter_chunks_by_metadata returns original chunks when no filters.""" + chunks = [{"text": "chunk1", "similarity": 0.9}] + metadata = {"filters": {}, "requested_fields": []} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result == chunks + + def test_filter_chunks_with_requested_fields(self, nodes): + """Test filter_chunks_by_metadata scores chunks with requested fields.""" + chunks = [ + {"text": "no field", "additional_context": {}, "similarity": 0.8}, + {"text": "has field", "additional_context": {"name": "test"}, "similarity": 0.7}, + ] + metadata = {"filters": {}, "requested_fields": ["name"]} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result[0]["text"] == "has field" + + def test_filter_chunks_with_list_metadata(self, nodes): + """Test filter_chunks_by_metadata handles list metadata values.""" + chunks = [ + { + "text": "has list", + "additional_context": {"tags": ["python", "django"]}, + "similarity": 0.8, + }, + { + "text": "no match", + "additional_context": {"tags": ["java", "spring"]}, + "similarity": 0.9, + }, + ] + metadata = {"filters": {"tags": "python"}, "requested_fields": []} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result[0]["text"] == "has list" + + def test_filter_chunks_exact_match(self, nodes): + """Test filter_chunks_by_metadata handles exact non-string matches.""" + chunks = [ + {"text": "exact", "additional_context": {"count": 42}, "similarity": 0.8}, + {"text": "no match", "additional_context": {"count": 10}, "similarity": 0.9}, + ] + metadata = {"filters": {"count": 42}, "requested_fields": []} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result[0]["text"] == "exact" + + def test_filter_chunks_content_match(self, nodes): + """Test filter_chunks_by_metadata scores content matches.""" + chunks = [ + {"text": "contains python code", "additional_context": {}, "similarity": 0.7}, + {"text": "something else", "additional_context": {}, "similarity": 0.9}, + ] + metadata = {"filters": {"lang": "python"}, "requested_fields": []} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result[0]["text"] == "contains python code" + + def test_filter_chunks_metadata_boost(self, nodes): + """Test filter_chunks_by_metadata adds score for metadata richness.""" + chunks = [ + {"text": "rich", "additional_context": {"a": 1, "b": 2, "c": 3}, "similarity": 0.7}, + {"text": "poor", "additional_context": {}, "similarity": 0.9}, + ] + metadata = {"filters": {"x": "y"}, "requested_fields": []} + + result = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert result[0]["text"] == "rich" + def test_extract_query_metadata_openai_error(self, nodes, mocker): mocker.patch( "apps.ai.agent.nodes.Prompt.get_metadata_extractor_prompt", return_value="sys prompt" @@ -118,6 +209,31 @@ def test_extract_query_metadata_openai_error(self, nodes, mocker): metadata = nodes.extract_query_metadata("query") assert metadata["intent"] == "general query" + def test_extract_query_metadata_prompt_not_found(self, nodes, mocker): + """Test extract_query_metadata raises when prompt not found.""" + mocker.patch("apps.ai.agent.nodes.Prompt.get_metadata_extractor_prompt", return_value=None) + + with pytest.raises(ObjectDoesNotExist, match="metadata-extractor-prompt"): + nodes.extract_query_metadata("query") + + def test_extract_query_metadata_success(self, nodes, mocker): + """Test successful metadata extraction from LLM.""" + mocker.patch( + "apps.ai.agent.nodes.Prompt.get_metadata_extractor_prompt", return_value="sys prompt" + ) + + mock_response = mocker.Mock() + mock_response.choices = [mocker.Mock()] + mock_response.choices[ + 0 + ].message.content = '{"entity_types": ["project"], "intent": "search"}' + nodes.openai_client.chat.completions.create.return_value = mock_response + + metadata = nodes.extract_query_metadata("find OWASP projects") + + assert metadata["entity_types"] == ["project"] + assert metadata["intent"] == "search" + def test_call_evaluator_openai_error(self, nodes, mocker): nodes.generator.prepare_context.return_value = "ctx" mocker.patch( @@ -127,3 +243,28 @@ def test_call_evaluator_openai_error(self, nodes, mocker): eval_result = nodes.call_evaluator(query="q", answer="a", context_chunks=[]) assert eval_result["feedback"] == "Evaluator error or invalid response." + + def test_call_evaluator_prompt_not_found(self, nodes, mocker): + """Test call_evaluator raises when prompt not found.""" + nodes.generator.prepare_context.return_value = "ctx" + mocker.patch("apps.ai.agent.nodes.Prompt.get_evaluator_system_prompt", return_value=None) + + with pytest.raises(ObjectDoesNotExist, match="evaluator-system-prompt"): + nodes.call_evaluator(query="q", answer="a", context_chunks=[]) + + def test_call_evaluator_success(self, nodes, mocker): + """Test successful evaluation from LLM.""" + nodes.generator.prepare_context.return_value = "ctx" + mocker.patch( + "apps.ai.agent.nodes.Prompt.get_evaluator_system_prompt", return_value="sys prompt" + ) + + mock_response = mocker.Mock() + mock_response.choices = [mocker.Mock()] + mock_response.choices[0].message.content = '{"complete": true, "feedback": null}' + nodes.openai_client.chat.completions.create.return_value = mock_response + + eval_result = nodes.call_evaluator(query="q", answer="a", context_chunks=[]) + + assert eval_result["complete"] + assert eval_result["feedback"] is None diff --git a/backend/tests/apps/api/rest/v0/issue_test.py b/backend/tests/apps/api/rest/v0/issue_test.py index 3211e4506e..dd6b487250 100644 --- a/backend/tests/apps/api/rest/v0/issue_test.py +++ b/backend/tests/apps/api/rest/v0/issue_test.py @@ -1,8 +1,17 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock import pytest +from ninja.responses import Response -from apps.api.rest.v0.issue import IssueDetail +from apps.api.rest.v0.issue import ( + IssueDetail, + IssueFilter, + get_issue, + list_issues, +) +from apps.github.models.issue import Issue as IssueModel class TestIssueSchema: @@ -36,3 +45,132 @@ def test_issue_schema(self, issue_data): assert issue.title == issue_data["title"] assert issue.updated_at == datetime.fromisoformat(issue_data["updated_at"]) assert issue.url == issue_data["url"] + + +class TestListIssues: + """Tests for list_issues view function.""" + + def test_list_issues_no_filter(self, mocker): + """Test listing issues without filters.""" + mock_qs = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = IssueFilter() + list_issues(request, filters, None) + + mock_qs.order_by.assert_called_once_with("-created_at", "-updated_at") + + def test_list_issues_with_organization_filter(self, mocker): + """Test listing issues with organization filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = IssueFilter(organization="OWASP") + list_issues(request, filters, None) + + mock_qs.filter.assert_called_with(repository__organization__login__iexact="OWASP") + + def test_list_issues_with_repository_filter(self, mocker): + """Test listing issues with repository filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = IssueFilter(organization="OWASP", repository="Nest") + list_issues(request, filters, None) + + mock_filtered.filter.assert_called_with(repository__name__iexact="Nest") + + def test_list_issues_with_state_filter(self, mocker): + """Test listing issues with state filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = IssueFilter(state="open") + list_issues(request, filters, None) + + def test_list_issues_with_ordering(self, mocker): + """Test listing issues with custom ordering.""" + mock_qs = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = IssueFilter() + list_issues(request, filters, "updated_at") + + mock_qs.order_by.assert_called_once_with("updated_at", "-updated_at") + + +class TestGetIssue: + """Tests for get_issue view function.""" + + def test_get_issue_success(self, mocker): + """Test getting a specific issue successfully.""" + mock_issue = MagicMock() + mock_qs = MagicMock() + mock_qs.get.return_value = mock_issue + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_issue(request, "OWASP", "Nest", 123) + + assert result == mock_issue + mock_qs.get.assert_called_once_with( + repository__organization__login__iexact="OWASP", + repository__name__iexact="Nest", + number=123, + ) + + def test_get_issue_not_found(self, mocker): + """Test getting a non-existent issue.""" + mock_qs = MagicMock() + mock_qs.get.side_effect = IssueModel.DoesNotExist + mocker.patch( + "apps.api.rest.v0.issue.IssueModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_issue(request, "OWASP", "NonExistent", 999) + + assert isinstance(result, Response) + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/milestone_test.py b/backend/tests/apps/api/rest/v0/milestone_test.py index 30819756b1..6a5e2f6054 100644 --- a/backend/tests/apps/api/rest/v0/milestone_test.py +++ b/backend/tests/apps/api/rest/v0/milestone_test.py @@ -1,8 +1,17 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock import pytest +from ninja.responses import Response -from apps.api.rest.v0.milestone import MilestoneDetail +from apps.api.rest.v0.milestone import ( + MilestoneDetail, + MilestoneFilter, + get_milestone, + list_milestones, +) +from apps.github.models.milestone import Milestone as MilestoneModel class TestMilestoneSchema: @@ -87,3 +96,132 @@ def test_milestone_schema_with_minimal_data(self): assert milestone.open_issues_count == 0 assert milestone.title == "Test Milestone" assert milestone.state == "open" + + +class TestListMilestones: + """Tests for list_milestones view function.""" + + def test_list_milestones_no_filter(self, mocker): + """Test listing milestones without filters.""" + mock_qs = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = MilestoneFilter() + list_milestones(request, filters, None) + + mock_qs.order_by.assert_called_once_with("-created_at", "-updated_at") + + def test_list_milestones_with_organization_filter(self, mocker): + """Test listing milestones with organization filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = MilestoneFilter(organization="OWASP") + list_milestones(request, filters, None) + + mock_qs.filter.assert_called_with(repository__organization__login__iexact="OWASP") + + def test_list_milestones_with_repository_filter(self, mocker): + """Test listing milestones with repository filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = MilestoneFilter(organization="OWASP", repository="Nest") + list_milestones(request, filters, None) + + mock_filtered.filter.assert_called_with(repository__name__iexact="Nest") + + def test_list_milestones_with_state_filter(self, mocker): + """Test listing milestones with state filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = MilestoneFilter(state="open") + list_milestones(request, filters, None) + + def test_list_milestones_with_ordering(self, mocker): + """Test listing milestones with custom ordering.""" + mock_qs = MagicMock() + mock_qs.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = MilestoneFilter() + list_milestones(request, filters, "updated_at") + + mock_qs.order_by.assert_called_once_with("updated_at", "-updated_at") + + +class TestGetMilestone: + """Tests for get_milestone view function.""" + + def test_get_milestone_success(self, mocker): + """Test getting a specific milestone successfully.""" + mock_milestone = MagicMock() + mock_qs = MagicMock() + mock_qs.get.return_value = mock_milestone + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_milestone(request, "OWASP", "Nest", 1) + + assert result == mock_milestone + mock_qs.get.assert_called_once_with( + repository__organization__login__iexact="OWASP", + repository__name__iexact="Nest", + number=1, + ) + + def test_get_milestone_not_found(self, mocker): + """Test getting a non-existent milestone.""" + mock_qs = MagicMock() + mock_qs.get.side_effect = MilestoneModel.DoesNotExist + mocker.patch( + "apps.api.rest.v0.milestone.MilestoneModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_milestone(request, "OWASP", "NonExistent", 999) + + assert isinstance(result, Response) + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/api/rest/v0/pagination_test.py b/backend/tests/apps/api/rest/v0/pagination_test.py new file mode 100644 index 0000000000..158e024f0a --- /dev/null +++ b/backend/tests/apps/api/rest/v0/pagination_test.py @@ -0,0 +1,152 @@ +"""Tests for CustomPagination class.""" + +from unittest.mock import MagicMock + +import pytest +from django.http import Http404 + +from apps.api.rest.v0.pagination import CustomPagination + + +class TestCustomPagination: + """Test cases for CustomPagination.""" + + def setup_method(self): + """Set up test fixtures.""" + self.pagination = CustomPagination() + + def _create_mock_queryset(self, items: list, total_count: int | None = None): + """Create a mock queryset with specified items.""" + mock_qs = MagicMock() + mock_qs.count.return_value = total_count if total_count is not None else len(items) + mock_qs.__getitem__ = lambda _self, s: items[s] + return mock_qs + + def test_paginate_queryset_basic(self): + """Test basic pagination with default parameters.""" + items = list(range(10)) + queryset = self._create_mock_queryset(items) + + pagination_input = CustomPagination.Input(page=1, page_size=10) + result = self.pagination.paginate_queryset(queryset, pagination_input) + + assert result["current_page"] == 1 + assert result["total_count"] == 10 + assert result["total_pages"] == 1 + assert not result["has_next"] + assert not result["has_previous"] + assert result["items"] == items + + def test_paginate_queryset_multiple_pages(self): + """Test pagination with multiple pages.""" + items = list(range(25)) + queryset = self._create_mock_queryset(items) + + pagination_input = CustomPagination.Input(page=2, page_size=10) + result = self.pagination.paginate_queryset(queryset, pagination_input) + + assert result["current_page"] == 2 + assert result["total_count"] == 25 + assert result["total_pages"] == 3 + assert result["has_next"] + assert result["has_previous"] + assert result["items"] == items[10:20] + + def test_paginate_queryset_last_page(self): + """Test pagination on the last page.""" + items = list(range(25)) + queryset = self._create_mock_queryset(items) + + pagination_input = CustomPagination.Input(page=3, page_size=10) + result = self.pagination.paginate_queryset(queryset, pagination_input) + + assert result["current_page"] == 3 + assert result["total_pages"] == 3 + assert not result["has_next"] + assert result["has_previous"] + assert result["items"] == items[20:25] + + def test_paginate_queryset_page_exceeds_total(self): + """Test that Http404 is raised when page exceeds total pages.""" + items = list(range(10)) + queryset = self._create_mock_queryset(items) + + pagination_input = CustomPagination.Input(page=5, page_size=10) + + with pytest.raises(Http404) as exc_info: + self.pagination.paginate_queryset(queryset, pagination_input) + + assert "Page 5 not found" in str(exc_info.value) + assert "Valid pages are 1 to 1" in str(exc_info.value) + + def test_paginate_queryset_empty_queryset(self): + """Test pagination with an empty queryset returns single page.""" + queryset = self._create_mock_queryset([]) + + pagination_input = CustomPagination.Input(page=1, page_size=10) + result = self.pagination.paginate_queryset(queryset, pagination_input) + + assert result["current_page"] == 1 + assert result["total_count"] == 0 + assert result["total_pages"] == 1 + assert not result["has_next"] + assert not result["has_previous"] + assert result["items"] == [] + + def test_paginate_queryset_empty_queryset_page_exceeds(self): + """Test that page 2 on empty queryset raises Http404.""" + queryset = self._create_mock_queryset([]) + + pagination_input = CustomPagination.Input(page=2, page_size=10) + + with pytest.raises(Http404): + self.pagination.paginate_queryset(queryset, pagination_input) + + def test_paginate_queryset_custom_page_size(self): + """Test pagination with custom page size.""" + items = list(range(100)) + queryset = self._create_mock_queryset(items) + + pagination_input = CustomPagination.Input(page=1, page_size=50) + result = self.pagination.paginate_queryset(queryset, pagination_input) + + assert result["total_pages"] == 2 + assert len(result["items"]) == 50 + assert result["has_next"] + + +class TestPaginationInputSchema: + """Test Pagination Input schema defaults and validation.""" + + def test_input_defaults(self): + """Test default values for pagination input.""" + input_schema = CustomPagination.Input() + assert input_schema.page == 1 + assert input_schema.page_size == 100 + + def test_input_custom_values(self): + """Test custom values for pagination input.""" + input_schema = CustomPagination.Input(page=5, page_size=25) + assert input_schema.page == 5 + assert input_schema.page_size == 25 + + +class TestPaginationOutputSchema: + """Test Pagination Output schema.""" + + def test_output_schema(self): + """Test that Output schema has expected fields.""" + output = CustomPagination.Output( + current_page=1, + has_next=True, + has_previous=False, + items=[1, 2, 3], + total_count=100, + total_pages=10, + ) + assert output.current_page == 1 + assert output.has_next + assert not output.has_previous + assert output.items == [1, 2, 3] + assert output.total_count == 100 + assert output.total_pages == 10 diff --git a/backend/tests/apps/api/rest/v0/release_test.py b/backend/tests/apps/api/rest/v0/release_test.py index 8aa5d649a5..6b7a7614cd 100644 --- a/backend/tests/apps/api/rest/v0/release_test.py +++ b/backend/tests/apps/api/rest/v0/release_test.py @@ -1,8 +1,17 @@ from datetime import datetime +from http import HTTPStatus +from unittest.mock import MagicMock import pytest +from ninja.responses import Response -from apps.api.rest.v0.release import ReleaseDetail +from apps.api.rest.v0.release import ( + ReleaseDetail, + ReleaseFilter, + get_release, + list_release, +) +from apps.github.models.release import Release as ReleaseModel class TestReleaseSchema: @@ -33,3 +42,132 @@ def test_release_schema(self, release_data): assert release.name == release_data["name"] assert release.published_at == datetime.fromisoformat(release_data["published_at"]) assert release.tag_name == release_data["tag_name"] + + +class TestListRelease: + """Tests for list_release view function.""" + + def test_list_release_no_filter(self, mocker): + """Test listing releases without filters.""" + mock_qs = MagicMock() + mock_qs.exclude.return_value.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = ReleaseFilter() + list_release(request, filters, None) + + mock_qs.order_by.assert_called_once_with("-published_at", "-created_at") + + def test_list_release_with_organization_filter(self, mocker): + """Test listing releases with organization filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.exclude.return_value.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = ReleaseFilter(organization="OWASP") + list_release(request, filters, None) + + mock_qs.filter.assert_called_with(repository__organization__login__iexact="OWASP") + + def test_list_release_with_repository_filter(self, mocker): + """Test listing releases with repository filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.exclude.return_value.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = ReleaseFilter(organization="OWASP", repository="Nest") + list_release(request, filters, None) + + mock_filtered.filter.assert_called_with(repository__name__iexact="Nest") + + def test_list_release_with_tag_filter(self, mocker): + """Test listing releases with tag_name filter.""" + mock_qs = MagicMock() + mock_filtered = MagicMock() + mock_qs.exclude.return_value.select_related.return_value = mock_qs + mock_qs.filter.return_value = mock_filtered + mock_filtered.filter.return_value = mock_filtered + mock_filtered.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = ReleaseFilter(tag_name="v1.0.0") + list_release(request, filters, None) + + def test_list_release_with_ordering(self, mocker): + """Test listing releases with custom ordering.""" + mock_qs = MagicMock() + mock_qs.exclude.return_value.select_related.return_value = mock_qs + mock_qs.order_by.return_value = [] + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + filters = ReleaseFilter() + list_release(request, filters, "created_at") + + mock_qs.order_by.assert_called_once_with("created_at", "-created_at") + + +class TestGetRelease: + """Tests for get_release view function.""" + + def test_get_release_success(self, mocker): + """Test getting a specific release successfully.""" + mock_release = MagicMock() + mock_qs = MagicMock() + mock_qs.get.return_value = mock_release + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_release(request, "OWASP", "Nest", "v1.0.0") + + assert result == mock_release + mock_qs.get.assert_called_once_with( + repository__organization__login__iexact="OWASP", + repository__name__iexact="Nest", + tag_name="v1.0.0", + ) + + def test_get_release_not_found(self, mocker): + """Test getting a non-existent release.""" + mock_qs = MagicMock() + mock_qs.get.side_effect = ReleaseModel.DoesNotExist + mocker.patch( + "apps.api.rest.v0.release.ReleaseModel.objects", + mock_qs, + ) + + request = MagicMock() + result = get_release(request, "OWASP", "NonExistent", "v99.0.0") + + assert isinstance(result, Response) + assert result.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/tests/apps/common/index_test.py b/backend/tests/apps/common/index_test.py index 485ce5488f..c064d0676b 100644 --- a/backend/tests/apps/common/index_test.py +++ b/backend/tests/apps/common/index_test.py @@ -205,3 +205,111 @@ def test_get_total_count_cache(self): assert result1 == result2 == TOTAL_COUNT self.mock_client.search_single_index.assert_called_once() + + def test_get_total_count_with_filters(self): + """Test get_total_count applies search filters.""" + mock_response = MagicMock() + mock_response.nb_hits = 10 + self.mock_client.search_single_index.return_value = mock_response + + IndexBase.get_total_count.cache_clear() + result = IndexBase.get_total_count("filtered_index", search_filters="is_active:true") + + assert result == 10 + call_args = self.mock_client.search_single_index.call_args + assert call_args[1]["search_params"]["filters"] == "is_active:true" + + def test_parse_synonyms_file_one_way_synonym(self): + """Test parsing one-way synonyms with colon separator.""" + file_content = "input_term: synonym1, synonym2, synonym3" + with patch("pathlib.Path.open", mock_open(read_data=file_content)): + result = IndexBase._parse_synonyms_file("test.txt") + + assert len(result) == 1 + assert result[0]["type"] == "oneWaySynonym" + assert result[0]["input"] == "input_term" + assert result[0]["synonyms"] == ["synonym1", "synonym2", "synonym3"] + + def test_parse_synonyms_file_two_way_synonym(self): + """Test parsing two-way synonyms without colon.""" + file_content = "term1, term2, term3" + with patch("pathlib.Path.open", mock_open(read_data=file_content)): + result = IndexBase._parse_synonyms_file("test.txt") + + assert len(result) == 1 + assert result[0]["type"] == "synonym" + assert result[0]["synonyms"] == ["term1", "term2", "term3"] + + def test_parse_synonyms_file_not_found(self): + """Test handling of file not found error.""" + with patch("pathlib.Path.open", side_effect=FileNotFoundError): + result = IndexBase._parse_synonyms_file("/nonexistent/path.txt") + + assert result is None + self.mock_logger.exception.assert_called_once() + + def test_reindex_synonyms_success(self): + """Test successful reindexing of synonyms.""" + synonyms = [ + {"objectID": "1", "type": "synonym", "synonyms": ["a", "b"]}, + {"objectID": "2", "type": "synonym", "synonyms": ["c", "d"]}, + ] + with patch.object(IndexBase, "_parse_synonyms_file", return_value=synonyms): + result = IndexBase.reindex_synonyms("owasp", "projects") + + assert result == 2 + self.mock_client.clear_synonyms.assert_called_once() + self.mock_client.save_synonyms.assert_called_once() + + def test_reindex_synonyms_no_synonyms(self): + """Test reindex_synonyms returns None when no synonyms.""" + with patch.object(IndexBase, "_parse_synonyms_file", return_value=None): + result = IndexBase.reindex_synonyms("test_app", "test_index") + + assert result is None + + def test_reindex_synonyms_algolia_exception(self): + """Test reindex_synonyms handles AlgoliaException.""" + synonyms = [{"objectID": "1", "type": "synonym", "synonyms": ["a", "b"]}] + self.mock_client.save_synonyms.side_effect = AlgoliaException("API Error") + + with patch.object(IndexBase, "_parse_synonyms_file", return_value=synonyms): + result = IndexBase.reindex_synonyms("owasp", "projects") + + assert result is None + self.mock_logger.exception.assert_called() + + def test_get_queryset_local_environment(self): + """Test get_queryset limits results in local environment.""" + mock_index = MagicMock() + mock_queryset = MagicMock() + mock_index.get_entities.return_value = mock_queryset + + self.mock_settings.IS_LOCAL_ENVIRONMENT = True + + IndexBase.get_queryset(mock_index) + + mock_queryset.__getitem__.assert_called() + + def test_get_queryset_non_local_environment(self): + """Test get_queryset returns full queryset in non-local environment.""" + mock_index = MagicMock() + mock_queryset = MagicMock() + mock_index.get_entities.return_value = mock_queryset + + self.mock_settings.IS_LOCAL_ENVIRONMENT = False + + result = IndexBase.get_queryset(mock_index) + + assert result == mock_queryset + + def test_get_client_with_ip_address(self): + """Test get_client sets X-Forwarded-For header with IP address.""" + with patch("apps.common.index.SearchConfig") as mock_config: + mock_config_instance = MagicMock() + mock_config_instance.headers = {} + mock_config.return_value = mock_config_instance + + IndexBase.get_client(ip_address="192.168.1.1") + + assert mock_config_instance.headers["X-Forwarded-For"] == "192.168.1.1" diff --git a/backend/tests/apps/core/models/__init__.py b/backend/tests/apps/core/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/core/models/prompt_test.py b/backend/tests/apps/core/models/prompt_test.py new file mode 100644 index 0000000000..0f761cddda --- /dev/null +++ b/backend/tests/apps/core/models/prompt_test.py @@ -0,0 +1,178 @@ +"""Tests for Prompt model.""" + +from unittest.mock import MagicMock, patch + +from apps.core.models.prompt import Prompt + + +class TestPromptModel: + """Test cases for Prompt model.""" + + def test_str(self): + """Test string representation returns name.""" + prompt = Prompt(name="Test Prompt", key="test-prompt") + assert str(prompt) == "Test Prompt" + + def test_save_generates_key(self): + """Test that save auto-generates key from name.""" + prompt = Prompt(name="My Test Prompt") + + with patch.object(Prompt.__bases__[0], "save"): + prompt.save() + + assert prompt.key == "my-test-prompt" + + def test_save_generates_key_with_special_chars(self): + """Test key generation handles special characters.""" + prompt = Prompt(name="Test Prompt! With @Special# Chars") + + with patch.object(Prompt.__bases__[0], "save"): + prompt.save() + + assert prompt.key == "test-prompt-with-special-chars" + + def test_get_text_exists(self): + """Test get_text returns text for existing prompt.""" + with patch.object(Prompt, "objects") as mock_objects: + mock_prompt = MagicMock() + mock_prompt.text = "This is the prompt text" + mock_objects.get.return_value = mock_prompt + + result = Prompt.get_text("test-key") + + assert result == "This is the prompt text" + mock_objects.get.assert_called_once_with(key="test-key") + + def test_get_text_not_exists(self): + """Test get_text returns empty string when prompt not found.""" + with ( + patch.object(Prompt, "objects") as mock_objects, + patch("apps.core.models.prompt.settings") as mock_settings, + patch("apps.core.models.prompt.logger") as mock_logger, + ): + mock_objects.get.side_effect = Prompt.DoesNotExist + mock_settings.OPEN_AI_SECRET_KEY = "real-key" # noqa: S105 + + result = Prompt.get_text("nonexistent-key") + + assert result == "" + mock_logger.warning.assert_called_once() + + def test_get_text_not_exists_no_warning_when_no_api_key(self): + """Test get_text doesn't log warning when OPEN_AI_SECRET_KEY is None.""" + with ( + patch.object(Prompt, "objects") as mock_objects, + patch("apps.core.models.prompt.settings") as mock_settings, + patch("apps.core.models.prompt.logger") as mock_logger, + ): + mock_objects.get.side_effect = Prompt.DoesNotExist + mock_settings.OPEN_AI_SECRET_KEY = "None" # noqa: S105 + + result = Prompt.get_text("nonexistent-key") + + assert result == "" + mock_logger.warning.assert_not_called() + + def test_get_evaluator_system_prompt(self): + """Test get_evaluator_system_prompt calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="evaluator text") as mock_get_text: + result = Prompt.get_evaluator_system_prompt() + + assert result == "evaluator text" + mock_get_text.assert_called_once_with("evaluator-system-prompt") + + def test_get_github_issue_hint(self): + """Test get_github_issue_hint calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="hint text") as mock_get_text: + result = Prompt.get_github_issue_hint() + + assert result == "hint text" + mock_get_text.assert_called_once_with("github-issue-hint") + + def test_get_github_issue_documentation_project_summary(self): + """Test get_github_issue_documentation_project_summary.""" + with patch.object(Prompt, "get_text", return_value="doc summary") as mock_get_text: + result = Prompt.get_github_issue_documentation_project_summary() + + assert result == "doc summary" + mock_get_text.assert_called_once_with("github-issue-documentation-project-summary") + + def test_get_github_issue_project_summary(self): + """Test get_github_issue_project_summary calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="project summary") as mock_get_text: + result = Prompt.get_github_issue_project_summary() + + assert result == "project summary" + mock_get_text.assert_called_once_with("github-issue-project-summary") + + def test_get_metadata_extractor_prompt(self): + """Test get_metadata_extractor_prompt calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="extractor text") as mock_get_text: + result = Prompt.get_metadata_extractor_prompt() + + assert result == "extractor text" + mock_get_text.assert_called_once_with("metadata-extractor-prompt") + + def test_get_owasp_chapter_suggested_location(self): + """Test get_owasp_chapter_suggested_location.""" + with patch.object(Prompt, "get_text", return_value="location text") as mock_get_text: + result = Prompt.get_owasp_chapter_suggested_location() + + assert result == "location text" + mock_get_text.assert_called_once_with("owasp-chapter-suggested-location") + + def test_get_owasp_chapter_summary(self): + """Test get_owasp_chapter_summary calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="chapter summary") as mock_get_text: + result = Prompt.get_owasp_chapter_summary() + + assert result == "chapter summary" + mock_get_text.assert_called_once_with("owasp-chapter-summary") + + def test_get_owasp_committee_summary(self): + """Test get_owasp_committee_summary calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="committee summary") as mock_get_text: + result = Prompt.get_owasp_committee_summary() + + assert result == "committee summary" + mock_get_text.assert_called_once_with("owasp-committee-summary") + + def test_get_owasp_event_suggested_location(self): + """Test get_owasp_event_suggested_location.""" + with patch.object(Prompt, "get_text", return_value="event location") as mock_get_text: + result = Prompt.get_owasp_event_suggested_location() + + assert result == "event location" + mock_get_text.assert_called_once_with("owasp-event-suggested-location") + + def test_get_owasp_event_summary(self): + """Test get_owasp_event_summary calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="event summary") as mock_get_text: + result = Prompt.get_owasp_event_summary() + + assert result == "event summary" + mock_get_text.assert_called_once_with("owasp-event-summary") + + def test_get_owasp_project_summary(self): + """Test get_owasp_project_summary calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="project summary") as mock_get_text: + result = Prompt.get_owasp_project_summary() + + assert result == "project summary" + mock_get_text.assert_called_once_with("owasp-project-summary") + + def test_get_rag_system_prompt(self): + """Test get_rag_system_prompt calls get_text with correct key.""" + with patch.object(Prompt, "get_text", return_value="rag prompt") as mock_get_text: + result = Prompt.get_rag_system_prompt() + + assert result == "rag prompt" + mock_get_text.assert_called_once_with("rag-system-prompt") + + def test_get_slack_question_detector_prompt(self): + """Test get_slack_question_detector_prompt.""" + with patch.object(Prompt, "get_text", return_value="slack prompt") as mock_get_text: + result = Prompt.get_slack_question_detector_prompt() + + assert result == "slack prompt" + mock_get_text.assert_called_once_with("slack-question-detector-system-prompt") diff --git a/backend/tests/apps/github/common_test.py b/backend/tests/apps/github/common_test.py index 259be0adce..1a3769231f 100644 --- a/backend/tests/apps/github/common_test.py +++ b/backend/tests/apps/github/common_test.py @@ -5,7 +5,7 @@ from django.utils import timezone from github.GithubException import UnknownObjectException -from apps.github.common import sync_repository +from apps.github.common import sync_issue_comments, sync_repository @pytest.fixture @@ -68,7 +68,6 @@ def gh_item_factory(): def _create_item(**kwargs): item = MagicMock() - # Set default attributes that are almost always needed item.updated_at = kwargs.pop("updated_at", timezone.now()) item.pull_request = kwargs.pop("pull_request", None) item.milestone = kwargs.pop("milestone", None) @@ -76,8 +75,6 @@ def _create_item(**kwargs): item.labels = kwargs.pop("labels", []) item.creator = kwargs.pop("creator", MagicMock()) item.get_labels = lambda: item.labels - - # Apply any other specific attributes for key, value in kwargs.items(): setattr(item, key, value) @@ -374,3 +371,170 @@ def test_sync_repository_full_scenario( repository=mock_repo, ) mock_common_deps["PullRequest"].update_data.return_value.labels.add.assert_called_once() + + +class TestSyncIssueComments: + """Tests for the sync_issue_comments function.""" + + @pytest.fixture + def mock_comment_deps(self, mocker): + """Mock all dependencies for the sync_issue_comments function.""" + return { + "User": mocker.patch("apps.github.common.User"), + "Comment": mocker.patch("apps.github.common.Comment"), + "logger": mocker.patch("apps.github.common.logger"), + } + + @pytest.fixture + def mock_gh_client(self): + """Provide a mock GitHub client.""" + return MagicMock() + + @pytest.fixture + def mock_issue(self): + """Provide a mock Issue model instance.""" + issue = MagicMock() + issue.number = 42 + issue.repository = MagicMock() + issue.repository.path = "owasp/test-repo" + issue.latest_comment = None + issue.updated_at = timezone.now() - td(days=1) + return issue + + def test_sync_issue_comments_no_repository( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test that sync_issue_comments logs warning when issue has no repository.""" + mock_issue.repository = None + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_comment_deps["logger"].warning.assert_called_once_with( + "Issue #%s has no repository, skipping", mock_issue.number + ) + + def test_sync_issue_comments_basic_success( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test successful comment sync.""" + mock_gh_comment = MagicMock() + mock_gh_comment.user = MagicMock() + mock_gh_comment.id = 123 + + mock_gh_repo = MagicMock() + mock_gh_issue = MagicMock() + mock_gh_repo.get_issue.return_value = mock_gh_issue + mock_gh_issue.get_comments.return_value = [mock_gh_comment] + mock_gh_client.get_repo.return_value = mock_gh_repo + + mock_comment_deps["User"].update_data.return_value = MagicMock() + mock_comment = MagicMock() + mock_comment_deps["Comment"].update_data.return_value = mock_comment + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_comment_deps["User"].update_data.assert_called_with(mock_gh_comment.user) + mock_comment_deps["Comment"].update_data.assert_called_once() + mock_comment_deps["Comment"].bulk_save.assert_called_once_with([mock_comment]) + + def test_sync_issue_comments_with_latest_comment( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments uses latest_comment time for 'since'.""" + mock_issue.latest_comment = MagicMock() + mock_issue.latest_comment.updated_at = timezone.now() - td(days=2) + mock_issue.latest_comment.created_at = timezone.now() - td(days=3) + + mock_gh_repo = MagicMock() + mock_gh_issue = MagicMock() + mock_gh_repo.get_issue.return_value = mock_gh_issue + mock_gh_issue.get_comments.return_value = [] + mock_gh_client.get_repo.return_value = mock_gh_repo + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_gh_issue.get_comments.assert_called_once_with( + since=mock_issue.latest_comment.updated_at + ) + + def test_sync_issue_comments_author_update_fails( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments skips comment when author update fails.""" + mock_gh_comment = MagicMock() + mock_gh_comment.user = MagicMock() + mock_gh_comment.id = 456 + + mock_gh_repo = MagicMock() + mock_gh_issue = MagicMock() + mock_gh_repo.get_issue.return_value = mock_gh_issue + mock_gh_issue.get_comments.return_value = [mock_gh_comment] + mock_gh_client.get_repo.return_value = mock_gh_repo + + mock_comment_deps["User"].update_data.return_value = None + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_comment_deps["logger"].warning.assert_called_with( + "Could not sync author for comment %s", mock_gh_comment.id + ) + mock_comment_deps["Comment"].update_data.assert_not_called() + mock_comment_deps["Comment"].bulk_save.assert_not_called() + + def test_sync_issue_comments_unknown_object_exception( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments handles UnknownObjectException.""" + mock_gh_client.get_repo.side_effect = UnknownObjectException( + status=404, data={}, headers={} + ) + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_comment_deps["logger"].warning.assert_called() + + def test_sync_issue_comments_unexpected_exception( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments handles unexpected exceptions.""" + mock_gh_client.get_repo.side_effect = RuntimeError("Unexpected error") + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_comment_deps["logger"].exception.assert_called() + + def test_sync_issue_comments_no_since_date( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments with no since date fallback.""" + mock_issue.latest_comment = None + mock_issue.updated_at = None + + mock_gh_repo = MagicMock() + mock_gh_issue = MagicMock() + mock_gh_repo.get_issue.return_value = mock_gh_issue + mock_gh_issue.get_comments.return_value = [] + mock_gh_client.get_repo.return_value = mock_gh_repo + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_gh_issue.get_comments.assert_called_once_with() + + def test_sync_issue_comments_latest_comment_uses_created_at( + self, mock_comment_deps, mock_gh_client, mock_issue + ): + """Test sync_issue_comments uses created_at when updated_at is None.""" + created_at = timezone.now() - td(days=5) + mock_issue.latest_comment = MagicMock() + mock_issue.latest_comment.updated_at = None + mock_issue.latest_comment.created_at = created_at + + mock_gh_repo = MagicMock() + mock_gh_issue = MagicMock() + mock_gh_repo.get_issue.return_value = mock_gh_issue + mock_gh_issue.get_comments.return_value = [] + mock_gh_client.get_repo.return_value = mock_gh_repo + + sync_issue_comments(mock_gh_client, mock_issue) + + mock_gh_issue.get_comments.assert_called_once_with(since=created_at) diff --git a/backend/tests/apps/owasp/admin/entity_channel_test.py b/backend/tests/apps/owasp/admin/entity_channel_test.py new file mode 100644 index 0000000000..c3637ad877 --- /dev/null +++ b/backend/tests/apps/owasp/admin/entity_channel_test.py @@ -0,0 +1,104 @@ +"""Tests for EntityChannel admin configuration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.admin.sites import AdminSite + +from apps.owasp.admin.entity_channel import ( + EntityChannelAdmin, + mark_as_reviewed, +) +from apps.owasp.models import EntityChannel + + +class TestMarkAsReviewedAction: + """Tests for mark_as_reviewed admin action.""" + + def test_mark_as_reviewed(self, mocker): + """Test marking selected EntityChannels as reviewed.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_queryset.update.return_value = 3 + + mock_messages = mocker.patch("apps.owasp.admin.entity_channel.messages") + + mark_as_reviewed(None, mock_request, mock_queryset) + + mock_queryset.update.assert_called_once_with(is_reviewed=True) + mock_messages.success.assert_called_once() + + +class TestEntityChannelAdmin: + """Tests for EntityChannelAdmin.""" + + target_module = "apps.owasp.admin.entity_channel" + + @pytest.fixture + def admin_instance(self): + """Create admin instance.""" + site = AdminSite() + return EntityChannelAdmin(EntityChannel, site) + + def test_channel_search_display_with_conversation(self, admin_instance, mocker): + """Test channel_search_display returns channel name for conversations.""" + mock_conversation = mocker.patch(f"{self.target_module}.Conversation") + mock_conv_instance = MagicMock() + mock_conv_instance.name = "general" + mock_conversation.objects.get.return_value = mock_conv_instance + + mock_channel_type = MagicMock() + mock_channel_type.model = "conversation" + + obj = MagicMock() + obj.channel_id = 123 + obj.channel_type = mock_channel_type + + result = admin_instance.channel_search_display(obj) + + assert result == "#general" + + def test_channel_search_display_conversation_not_found(self, admin_instance, mocker): + """Test channel_search_display when conversation doesn't exist.""" + mock_conversation = mocker.patch(f"{self.target_module}.Conversation") + mock_conversation.DoesNotExist = Exception + mock_conversation.objects.get.side_effect = mock_conversation.DoesNotExist + + mock_channel_type = MagicMock() + mock_channel_type.model = "conversation" + + obj = MagicMock() + obj.channel_id = 999 + obj.channel_type = mock_channel_type + + result = admin_instance.channel_search_display(obj) + + assert "999" in result + assert "not found" in result + + def test_channel_search_display_no_channel_id(self, admin_instance): + """Test channel_search_display returns dash when no channel_id.""" + obj = MagicMock() + obj.channel_id = None + obj.channel_type = None + + result = admin_instance.channel_search_display(obj) + + assert result == "-" + + def test_get_form(self, admin_instance, mocker): + """Test get_form adds conversation content type id.""" + mock_content_type = mocker.patch(f"{self.target_module}.ContentType") + mock_ct_instance = MagicMock() + mock_ct_instance.id = 42 + mock_content_type.objects.get_for_model.return_value = mock_ct_instance + + mock_request = MagicMock() + + with patch.object(EntityChannelAdmin.__bases__[0], "get_form") as mock_super_get_form: + mock_form = MagicMock() + mock_super_get_form.return_value = mock_form + + result = admin_instance.get_form(mock_request) + + assert result.conversation_content_type_id == 42 diff --git a/backend/tests/apps/owasp/admin/entity_member_test.py b/backend/tests/apps/owasp/admin/entity_member_test.py new file mode 100644 index 0000000000..c293ed67fa --- /dev/null +++ b/backend/tests/apps/owasp/admin/entity_member_test.py @@ -0,0 +1,122 @@ +"""Tests for EntityMemberAdmin class.""" + +from unittest.mock import MagicMock, patch + +from django.contrib.admin.sites import AdminSite + +from apps.owasp.admin.entity_member import EntityMemberAdmin +from apps.owasp.models.entity_member import EntityMember + + +class TestEntityMemberAdmin: + """Test cases for EntityMemberAdmin.""" + + def setup_method(self): + """Set up test fixtures.""" + self.site = AdminSite() + self.admin = EntityMemberAdmin(EntityMember, self.site) + + def test_approve_members_action(self): + """Test the approve_members admin action.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_queryset.update.return_value = 3 + + self.admin.approve_members(mock_request, mock_queryset) + + mock_queryset.update.assert_called_once_with(is_active=True, is_reviewed=True) + mock_request.assert_not_called() + + def test_entity_display_with_entity(self): + """Test entity display method when entity exists.""" + mock_obj = MagicMock() + mock_obj.entity = MagicMock() + mock_obj.entity_type = MagicMock() + mock_obj.entity_type.app_label = "owasp" + mock_obj.entity_type.model = "project" + mock_obj.entity_id = 1 + + with patch("apps.owasp.admin.entity_member.reverse") as mock_reverse: + mock_reverse.return_value = "/admin/owasp/project/1/change/" + result = self.admin.entity(mock_obj) + + assert "href" in result + assert 'target="_blank"' in result + + def test_entity_display_without_entity(self): + """Test entity display method when entity is None.""" + mock_obj = MagicMock() + mock_obj.entity = None + + result = self.admin.entity(mock_obj) + + assert result == "-" + + def test_owasp_url_display_with_entity(self): + """Test OWASP URL display when entity exists.""" + mock_obj = MagicMock() + mock_obj.entity = MagicMock() + mock_obj.entity.owasp_url = "https://owasp.org/www-project-test" + + result = self.admin.owasp_url(mock_obj) + + assert "href" in result + assert "https://owasp.org/www-project-test" in result + assert "↗️" in result + + def test_owasp_url_display_without_entity(self): + """Test OWASP URL display when entity is None.""" + mock_obj = MagicMock() + mock_obj.entity = None + + result = self.admin.owasp_url(mock_obj) + + assert result == "-" + + def test_get_search_results_without_search_term(self): + """Test get_search_results without a search term.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + + with patch.object( + EntityMemberAdmin.__bases__[0], + "get_search_results", + return_value=(mock_queryset, False), + ): + result_qs, use_distinct = self.admin.get_search_results( + mock_request, mock_queryset, "" + ) + + assert result_qs == mock_queryset + assert not use_distinct + + def test_get_search_results_with_search_term(self): + """Test get_search_results with a search term finds entities.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_queryset.__or__ = MagicMock(return_value=mock_queryset) + + with ( + patch.object( + EntityMemberAdmin.__bases__[0], + "get_search_results", + return_value=(mock_queryset, False), + ), + patch("apps.owasp.admin.entity_member.Project") as mock_project, + patch("apps.owasp.admin.entity_member.Chapter") as mock_chapter, + patch("apps.owasp.admin.entity_member.Committee") as mock_committee, + patch("apps.owasp.admin.entity_member.ContentType") as mock_ct, + ): + mock_project.objects.filter.return_value.values_list.return_value = [1, 2] + mock_chapter.objects.filter.return_value.values_list.return_value = [3] + mock_committee.objects.filter.return_value.values_list.return_value = [] + mock_ct.objects.get_for_model.return_value = MagicMock() + + self.admin.model = MagicMock() + self.admin.model.objects.filter.return_value = MagicMock() + + _result_qs, use_distinct = self.admin.get_search_results( + mock_request, mock_queryset, "test-project" + ) + + assert use_distinct diff --git a/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py index 010ca5fa0d..97ad154f91 100644 --- a/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py @@ -1,12 +1,16 @@ """Test Cases for Project Health Metrics GraphQL Queries.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode -from apps.owasp.api.internal.queries.project_health_metrics import ProjectHealthMetricsQuery +from apps.owasp.api.internal.queries.project_health_metrics import ( + MAX_LIMIT, + MAX_OFFSET, + ProjectHealthMetricsQuery, +) class TestProjectHealthMetricsQuery: @@ -110,3 +114,87 @@ def test_project_health_metrics_distinct_length(self, mock_get_latest_metrics): result = query.project_health_metrics_distinct_length() assert result == 42 mock_get_latest_metrics.return_value.count.assert_called_once() + + +class TestProjectHealthMetricsPagination: + """Test cases for pagination edge cases in ProjectHealthMetricsQuery.""" + + def test_project_health_metrics_negative_offset_returns_empty(self): + """Test that negative offset returns empty list.""" + query = ProjectHealthMetricsQuery() + pagination = MagicMock() + pagination.offset = -1 + pagination.limit = 10 + + result = query.project_health_metrics(pagination=pagination) + assert result == [] + + def test_project_health_metrics_zero_limit_returns_empty(self): + """Test that zero limit returns empty list.""" + query = ProjectHealthMetricsQuery() + pagination = MagicMock() + pagination.offset = 0 + pagination.limit = 0 + + result = query.project_health_metrics(pagination=pagination) + assert result == [] + + def test_project_health_metrics_negative_limit_returns_empty(self): + """Test that negative limit returns empty list.""" + query = ProjectHealthMetricsQuery() + pagination = MagicMock() + pagination.offset = 0 + pagination.limit = -5 + + result = query.project_health_metrics(pagination=pagination) + assert result == [] + + @patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_latest_health_metrics" + ) + def test_project_health_metrics_offset_clamped_to_max(self, mock_get_latest_metrics): + """Test that offset is clamped to MAX_OFFSET.""" + query = ProjectHealthMetricsQuery() + pagination = MagicMock() + pagination.offset = 50000 + pagination.limit = None + + mock_get_latest_metrics.return_value = [] + query.project_health_metrics(pagination=pagination) + + assert pagination.offset == MAX_OFFSET + + @patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_latest_health_metrics" + ) + def test_project_health_metrics_limit_clamped_to_max(self, mock_get_latest_metrics): + """Test that limit is clamped to MAX_LIMIT.""" + query = ProjectHealthMetricsQuery() + pagination = MagicMock() + pagination.offset = 0 + pagination.limit = 5000 + + mock_get_latest_metrics.return_value = [] + query.project_health_metrics(pagination=pagination) + + assert pagination.limit == MAX_LIMIT + + @patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_latest_health_metrics" + ) + @patch("strawberry_django.filters.apply") + def test_project_health_metrics_distinct_length_with_filters( + self, mock_apply, mock_get_latest_metrics + ): + """Test distinct_length with filters applied.""" + mock_queryset = MagicMock() + mock_queryset.count.return_value = 10 + mock_get_latest_metrics.return_value = mock_queryset + mock_apply.return_value = mock_queryset + + mock_filters = MagicMock() + query = ProjectHealthMetricsQuery() + result = query.project_health_metrics_distinct_length(filters=mock_filters) + + assert result == 10 + mock_apply.assert_called_once_with(mock_filters, mock_queryset) diff --git a/backend/tests/apps/owasp/api/internal/queries/project_test.py b/backend/tests/apps/owasp/api/internal/queries/project_test.py index a5b05c6eb1..5a7bbc6ff7 100644 --- a/backend/tests/apps/owasp/api/internal/queries/project_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/project_test.py @@ -2,6 +2,7 @@ import pytest +from apps.github.models.user import User as GithubUser from apps.owasp.api.internal.nodes.project import ProjectNode from apps.owasp.api.internal.queries.project import ProjectQuery from apps.owasp.models.project import Project @@ -66,3 +67,139 @@ def test_resolve_project_not_found(self, mock_info): assert result is None mock_get.assert_called_once_with(key="www-project-non-existent") + + +class TestRecentProjectsResolution: + """Test cases for resolving recent_projects field.""" + + def test_recent_projects_with_positive_limit(self): + """Test recent_projects returns list within limit.""" + mock_projects = [Mock(spec=Project), Mock(spec=Project)] + + with patch("apps.owasp.models.project.Project.objects.filter") as mock_filter: + mock_filter.return_value.order_by.return_value.__getitem__ = Mock( + return_value=mock_projects + ) + + query = ProjectQuery() + result = query.__class__.__dict__["recent_projects"](query, limit=5) + + assert result == mock_projects + + def test_recent_projects_limit_zero_returns_empty(self): + """Test recent_projects returns empty list when limit is 0.""" + query = ProjectQuery() + result = query.__class__.__dict__["recent_projects"](query, limit=0) + + assert result == [] + + def test_recent_projects_negative_limit_returns_empty(self): + """Test recent_projects returns empty list when limit is negative.""" + query = ProjectQuery() + result = query.__class__.__dict__["recent_projects"](query, limit=-5) + + assert result == [] + + +class TestSearchProjectsResolution: + """Test cases for resolving search_projects field.""" + + def test_search_projects_with_valid_query(self): + """Test search_projects returns matching projects.""" + mock_projects = [Mock(spec=Project)] + + with patch("apps.owasp.models.project.Project.objects.filter") as mock_filter: + mock_filter.return_value.order_by.return_value.__getitem__ = Mock( + return_value=mock_projects + ) + + query = ProjectQuery() + result = query.__class__.__dict__["search_projects"](query, query="test") + + assert result == mock_projects + + def test_search_projects_query_too_short(self): + """Test search_projects returns empty for query < MIN_SEARCH_QUERY_LENGTH.""" + query = ProjectQuery() + result = query.__class__.__dict__["search_projects"](query, query="ab") + + assert result == [] + + def test_search_projects_query_too_long(self): + """Test search_projects returns empty for query > MAX_SEARCH_QUERY_LENGTH.""" + query = ProjectQuery() + long_query = "a" * 101 + result = query.__class__.__dict__["search_projects"](query, query=long_query) + + assert result == [] + + def test_search_projects_whitespace_trimmed(self): + """Test search_projects trims whitespace before checking length.""" + query = ProjectQuery() + result = query.__class__.__dict__["search_projects"](query, query=" ab ") + + assert result == [] + + +class TestIsProjectLeaderResolution: + """Test cases for resolving is_project_leader field.""" + + @pytest.fixture + def mock_info(self): + return Mock() + + def test_is_project_leader_user_not_found(self, mock_info): + """Test is_project_leader returns False when user doesn't exist.""" + with patch("apps.owasp.api.internal.queries.project.GithubUser.objects.get") as mock_get: + mock_get.side_effect = GithubUser.DoesNotExist + + query = ProjectQuery() + result = query.__class__.__dict__["is_project_leader"]( + query, info=mock_info, login="nonexistent" + ) + + assert not result + + def test_is_project_leader_user_is_leader(self, mock_info): + """Test is_project_leader returns True when user is a leader.""" + mock_user = Mock() + mock_user.login = "testuser" + mock_user.name = "Test User" + + with ( + patch( + "apps.owasp.api.internal.queries.project.GithubUser.objects.get" + ) as mock_get_user, + patch("apps.owasp.models.project.Project.objects.filter") as mock_filter, + ): + mock_get_user.return_value = mock_user + mock_filter.return_value.exists.return_value = True + + query = ProjectQuery() + result = query.__class__.__dict__["is_project_leader"]( + query, info=mock_info, login="testuser" + ) + + assert result + + def test_is_project_leader_user_not_leader(self, mock_info): + """Test is_project_leader returns False when user is not a leader.""" + mock_user = Mock() + mock_user.login = "testuser" + mock_user.name = "Test User" + + with ( + patch( + "apps.owasp.api.internal.queries.project.GithubUser.objects.get" + ) as mock_get_user, + patch("apps.owasp.models.project.Project.objects.filter") as mock_filter, + ): + mock_get_user.return_value = mock_user + mock_filter.return_value.exists.return_value = False + + query = ProjectQuery() + result = query.__class__.__dict__["is_project_leader"]( + query, info=mock_info, login="testuser" + ) + + assert not result diff --git a/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py b/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py new file mode 100644 index 0000000000..9783ddcf2f --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py @@ -0,0 +1,82 @@ +"""Tests for SnapshotQuery.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.api.internal.queries.snapshot import SnapshotQuery +from apps.owasp.models.snapshot import Snapshot + + +class TestSnapshotQuery: + """Test cases for SnapshotQuery.""" + + def setup_method(self): + """Set up test fixtures.""" + self.query = SnapshotQuery() + + def test_snapshot_query_has_strawberry_definition(self): + """Check if SnapshotQuery has valid Strawberry definition.""" + assert hasattr(SnapshotQuery, "__strawberry_definition__") + + field_names = [field.name for field in SnapshotQuery.__strawberry_definition__.fields] + assert "snapshot" in field_names + assert "snapshots" in field_names + + def test_snapshot_exists(self): + """Test snapshot returns snapshot when found.""" + mock_snapshot = MagicMock(spec=Snapshot) + + with patch("apps.owasp.models.snapshot.Snapshot.objects.get") as mock_get: + mock_get.return_value = mock_snapshot + + result = self.query.__class__.__dict__["snapshot"](self.query, key="test-key") + + assert result == mock_snapshot + mock_get.assert_called_once_with( + key="test-key", + status=Snapshot.Status.COMPLETED, + ) + + def test_snapshot_not_exists(self): + """Test snapshot returns None when not found.""" + with patch("apps.owasp.models.snapshot.Snapshot.objects.get") as mock_get: + mock_get.side_effect = Snapshot.DoesNotExist + + result = self.query.__class__.__dict__["snapshot"](self.query, key="nonexistent") + + assert result is None + + def test_snapshots_with_positive_limit(self): + """Test snapshots returns list with positive limit.""" + mock_snapshots = [MagicMock(spec=Snapshot), MagicMock(spec=Snapshot)] + + with patch("apps.owasp.models.snapshot.Snapshot.objects.filter") as mock_filter: + mock_filter.return_value.order_by.return_value.__getitem__ = MagicMock( + return_value=mock_snapshots + ) + + result = self.query.__class__.__dict__["snapshots"](self.query, limit=5) + + assert result == mock_snapshots + + def test_snapshots_with_zero_limit_returns_empty(self): + """Test snapshots returns empty list when limit is 0.""" + result = self.query.__class__.__dict__["snapshots"](self.query, limit=0) + + assert result == [] + + def test_snapshots_with_negative_limit_returns_empty(self): + """Test snapshots returns empty list when limit is negative.""" + result = self.query.__class__.__dict__["snapshots"](self.query, limit=-10) + + assert result == [] + + def test_snapshots_limit_clamped_to_max(self): + """Test snapshots clamps limit to MAX_LIMIT.""" + mock_snapshots = [MagicMock(spec=Snapshot)] + + with patch("apps.owasp.models.snapshot.Snapshot.objects.filter") as mock_filter: + mock_filter.return_value.order_by.return_value.__getitem__ = MagicMock( + return_value=mock_snapshots + ) + result = self.query.__class__.__dict__["snapshots"](self.query, limit=500) + assert result == mock_snapshots diff --git a/backend/tests/apps/owasp/api/internal/queries/stats_test.py b/backend/tests/apps/owasp/api/internal/queries/stats_test.py new file mode 100644 index 0000000000..f733484a4a --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/queries/stats_test.py @@ -0,0 +1,62 @@ +"""Tests for StatsQuery.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.api.internal.queries.stats import StatsQuery + + +class TestStatsQuery: + """Test cases for StatsQuery.""" + + def test_stats_overview_returns_node(self): + """Test stats_overview returns StatsNode with calculated values.""" + mock_workspace = MagicMock() + mock_workspace.total_members_count = 5500 + + with ( + patch("apps.owasp.api.internal.queries.stats.Project") as mock_project, + patch("apps.owasp.api.internal.queries.stats.Chapter") as mock_chapter, + patch("apps.owasp.api.internal.queries.stats.User") as mock_user, + patch("apps.owasp.api.internal.queries.stats.Workspace") as mock_workspace_cls, + ): + mock_project.active_projects_count.return_value = 275 + mock_chapter.active_chapters_count.return_value = 342 + mock_user.objects.count.return_value = 15234 + mock_filter = mock_chapter.objects.filter.return_value.exclude.return_value + mock_filter.values.return_value.distinct.return_value.count.return_value = 98 + mock_workspace_cls.get_default_workspace.return_value = mock_workspace + + query = StatsQuery() + result = query.stats_overview() + + assert result.active_projects_stats == 270 + assert result.active_chapters_stats == 340 + assert result.contributors_stats == 15000 + assert result.countries_stats == 90 + assert result.slack_workspace_stats == 5000 + + def test_stats_overview_no_workspace(self): + """Test stats_overview when no default workspace exists.""" + with ( + patch("apps.owasp.api.internal.queries.stats.Project") as mock_project, + patch("apps.owasp.api.internal.queries.stats.Chapter") as mock_chapter, + patch("apps.owasp.api.internal.queries.stats.User") as mock_user, + patch("apps.owasp.api.internal.queries.stats.Workspace") as mock_workspace_cls, + ): + mock_project.active_projects_count.return_value = 10 + mock_chapter.active_chapters_count.return_value = 10 + mock_user.objects.count.return_value = 1000 + mock_filter = mock_chapter.objects.filter.return_value.exclude.return_value + mock_filter.values.return_value.distinct.return_value.count.return_value = 10 + mock_workspace_cls.get_default_workspace.return_value = None + + query = StatsQuery() + result = query.stats_overview() + assert result.slack_workspace_stats == 0 + + def test_stats_overview_has_strawberry_definition(self): + """Check if StatsQuery has valid Strawberry definition.""" + assert hasattr(StatsQuery, "__strawberry_definition__") + + field_names = [field.name for field in StatsQuery.__strawberry_definition__.fields] + assert "stats_overview" in field_names diff --git a/backend/tests/apps/owasp/management/commands/process_snapshots_test.py b/backend/tests/apps/owasp/management/commands/process_snapshots_test.py index 9b5b9d7d91..d924f68be4 100644 --- a/backend/tests/apps/owasp/management/commands/process_snapshots_test.py +++ b/backend/tests/apps/owasp/management/commands/process_snapshots_test.py @@ -41,12 +41,10 @@ def test_process_snapshots_error(self): command = Command() error_message = "Test error" - # Create mock snapshot using MagicMock mock_snapshot = mock.MagicMock(spec=Snapshot) mock_snapshot.id = 1 mock_snapshot.status = "pending" - # Create mock queryset mock_queryset = mock.MagicMock() mock_queryset.exists.return_value = True mock_queryset.__iter__.return_value = [mock_snapshot] @@ -63,7 +61,6 @@ def test_process_snapshots_error(self): ): command.process_snapshots() - # error handling assert mock_snapshot.status == "error" assert mock_snapshot.error_message == f"Error processing snapshot 1: {error_message}" mock_logger.exception.assert_called_once_with( @@ -101,3 +98,82 @@ def test_handle_error(self): command.handle() assert str(exc_info.value) == f"Failed to process snapshot: {error_message}" + + def test_process_snapshot_success(self): + """Test process_snapshot successfully processes a snapshot.""" + command = Command() + + mock_snapshot = mock.MagicMock(spec=Snapshot) + mock_snapshot.id = 1 + mock_snapshot.start_at = timezone.now() + mock_snapshot.end_at = timezone.now() + + mock_chapters = mock.MagicMock() + mock_chapters.filter.return_value = mock_chapters + mock_chapters.count.return_value = 2 + + mock_projects = mock.MagicMock() + mock_projects.filter.return_value = mock_projects + mock_projects.count.return_value = 3 + + mock_issues = mock.MagicMock() + mock_issues.count.return_value = 5 + + mock_releases = mock.MagicMock() + mock_releases.filter.return_value = mock_releases + mock_releases.count.return_value = 1 + + mock_users = mock.MagicMock() + mock_users.count.return_value = 10 + + with ( + mock.patch.object(command, "get_new_items") as mock_get_new_items, + mock.patch("apps.owasp.management.commands.owasp_process_snapshots.logger"), + ): + mock_get_new_items.side_effect = [ + mock_chapters, + mock_issues, + mock_projects, + mock_releases, + mock_users, + ] + + command.process_snapshot(mock_snapshot) + + assert mock_snapshot.status == Snapshot.Status.COMPLETED + mock_snapshot.save.assert_called() + + def test_process_snapshot_exception(self): + """Test process_snapshot raises SnapshotProcessingError on exception.""" + command = Command() + + mock_snapshot = mock.MagicMock(spec=Snapshot) + mock_snapshot.id = 1 + mock_snapshot.start_at = timezone.now() + mock_snapshot.end_at = timezone.now() + + with ( + mock.patch.object(command, "get_new_items", side_effect=Exception("Database error")), + mock.patch("apps.owasp.management.commands.owasp_process_snapshots.logger"), + pytest.raises(SnapshotProcessingError) as exc_info, + ): + command.process_snapshot(mock_snapshot) + + assert "Failed to process snapshot" in str(exc_info.value) + + def test_get_new_items(self): + """Test get_new_items filters by date range.""" + command = Command() + start_at = timezone.now() + end_at = timezone.now() + + mock_model = mock.MagicMock() + mock_queryset = mock.MagicMock() + mock_model.objects.filter.return_value = mock_queryset + + result = command.get_new_items(mock_model, start_at, end_at) + + assert result == mock_queryset + mock_model.objects.filter.assert_called_once_with( + created_at__gte=start_at, created_at__lte=end_at + ) diff --git a/backend/tests/apps/owasp/models/committee_test.py b/backend/tests/apps/owasp/models/committee_test.py index dd006c50d0..b9f969510b 100644 --- a/backend/tests/apps/owasp/models/committee_test.py +++ b/backend/tests/apps/owasp/models/committee_test.py @@ -99,3 +99,70 @@ def test_from_github(self): assert committee.created_at == owasp_repository.created_at assert committee.name == owasp_repository.title assert committee.updated_at == owasp_repository.updated_at + + def test_nest_key(self): + """Test nest_key property strips www-committee- prefix.""" + committee = Committee(key="www-committee-chapter") + assert committee.nest_key == "chapter" + + def test_nest_key_complex(self): + """Test nest_key with multi-word key.""" + committee = Committee(key="www-committee-web-security") + assert committee.nest_key == "web-security" + + def test_update_data_creates_new(self): + """Test update_data creates new committee when not found.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-committee-test" + + mock_repository = Mock() + + with patch.object(Committee, "objects") as mock_manager: + mock_manager.get.side_effect = Committee.DoesNotExist + + with ( + patch.object(Committee, "from_github") as mock_from_github, + patch.object(Committee, "save") as mock_save, + ): + result = Committee.update_data(mock_gh_repository, mock_repository, save=True) + + mock_manager.get.assert_called_once_with(key="www-committee-test") + mock_from_github.assert_called_once_with(mock_repository) + mock_save.assert_called_once() + assert isinstance(result, Committee) + + def test_update_data_updates_existing(self): + """Test update_data updates existing committee.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-committee-existing" + + mock_repository = Mock() + existing_committee = Mock(spec=Committee) + + with patch.object(Committee, "objects") as mock_manager: + mock_manager.get.return_value = existing_committee + + result = Committee.update_data(mock_gh_repository, mock_repository, save=False) + + mock_manager.get.assert_called_once_with(key="www-committee-existing") + existing_committee.from_github.assert_called_once_with(mock_repository) + existing_committee.save.assert_not_called() + assert result == existing_committee + + def test_update_data_with_save_false(self): + """Test update_data does not save when save=False.""" + mock_gh_repository = Mock() + mock_gh_repository.name = "www-committee-nosave" + + mock_repository = Mock() + + with patch.object(Committee, "objects") as mock_manager: + mock_manager.get.side_effect = Committee.DoesNotExist + + with ( + patch.object(Committee, "from_github"), + patch.object(Committee, "save") as mock_save, + ): + Committee.update_data(mock_gh_repository, mock_repository, save=False) + + mock_save.assert_not_called() diff --git a/backend/tests/apps/owasp/models/entity_member_test.py b/backend/tests/apps/owasp/models/entity_member_test.py new file mode 100644 index 0000000000..1a1b9b83e9 --- /dev/null +++ b/backend/tests/apps/owasp/models/entity_member_test.py @@ -0,0 +1,121 @@ +"""Tests for EntityMember model.""" + +from unittest.mock import MagicMock, patch + +from django.contrib.contenttypes.models import ContentType + +from apps.owasp.models.entity_member import EntityMember + + +class TestEntityMemberModel: + """Test cases for EntityMember model.""" + + def test_str_with_member_login(self): + """Test string representation uses member login when available.""" + entity_member = EntityMember( + member_name="Test User", + role=EntityMember.Role.LEADER, + ) + + assert entity_member.role == EntityMember.Role.LEADER + assert entity_member.member_name == "Test User" + assert entity_member.get_role_display() == "Leader" + + def test_str_without_member_uses_name(self): + """Test string representation uses member_name when member is None.""" + entity_member = EntityMember( + member_name="John Doe", + role=EntityMember.Role.MEMBER, + ) + + assert entity_member.member_name == "John Doe" + assert entity_member.get_role_display() == "Member" + + def test_role_choices(self): + """Test that role choices are correctly defined.""" + assert EntityMember.Role.CANDIDATE == "candidate" + assert EntityMember.Role.LEADER == "leader" + assert EntityMember.Role.MEMBER == "member" + + def test_update_data_finds_existing(self): + """Test update_data finds and updates existing EntityMember.""" + existing_member = MagicMock(spec=EntityMember) + mock_content_type = MagicMock(spec=ContentType) + + with patch("apps.owasp.models.entity_member.EntityMember.objects") as mock_manager: + mock_manager.get.return_value = existing_member + + data = { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "Existing User", + "role": EntityMember.Role.MEMBER, + "member_email": "updated@example.com", + "order": 2, + } + + result = EntityMember.update_data(data, save=False) + + assert result == existing_member + mock_manager.get.assert_called_once_with( + entity_id=1, + entity_type=mock_content_type, + member_name="Existing User", + role=EntityMember.Role.MEMBER, + ) + existing_member.from_dict.assert_called_once_with(data) + + def test_update_data_calls_save_when_requested(self): + """Test update_data saves when save=True.""" + existing_member = MagicMock(spec=EntityMember) + mock_content_type = MagicMock(spec=ContentType) + + with patch("apps.owasp.models.entity_member.EntityMember.objects") as mock_manager: + mock_manager.get.return_value = existing_member + + data = { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "Test", + "role": EntityMember.Role.CANDIDATE, + } + + EntityMember.update_data(data, save=True) + + existing_member.save.assert_called_once() + + def test_update_data_does_not_save_when_false(self): + """Test update_data does not save when save=False.""" + existing_member = MagicMock(spec=EntityMember) + mock_content_type = MagicMock(spec=ContentType) + + with patch("apps.owasp.models.entity_member.EntityMember.objects") as mock_manager: + mock_manager.get.return_value = existing_member + + data = { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "Test", + "role": EntityMember.Role.CANDIDATE, + } + + EntityMember.update_data(data, save=False) + + existing_member.save.assert_not_called() + + +class TestEntityMemberModelMeta: + """Test EntityMember model meta options.""" + + def test_model_has_indexes(self): + """Test that the model defines indexes.""" + meta = EntityMember._meta + assert len(meta.indexes) > 0 + + def test_model_db_table(self): + """Test the database table name.""" + assert EntityMember._meta.db_table == "owasp_entity_members" + + def test_model_verbose_name(self): + """Test the model verbose name.""" + assert EntityMember._meta.verbose_name == "entity member" diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index 8828c74aa6..6bfcf5c88a 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -153,3 +153,306 @@ def test_category_mapping(self, category_str, expected_category): mock_normalize_url.return_value = "" event.from_dict(category_str, data) assert event.category == expected_category + + +class TestEventUpcomingEvents: + """Test cases for upcoming_events static method.""" + + def test_upcoming_events(self): + """Test upcoming_events returns future events.""" + with patch.object(Event, "objects") as mock_objects: + mock_qs = Mock() + mock_objects.filter.return_value.exclude.return_value.order_by.return_value = mock_qs + + result = Event.upcoming_events() + + assert result == mock_qs + mock_objects.filter.assert_called_once() + + +class TestEventUpdateData: + """Test cases for update_data method.""" + + def test_update_data_new_event(self): + """Test update_data creates new event when not found.""" + category = "Global" + data = { + "name": "New Event", + "start-date": date(2025, 5, 26), + "dates": "May 26-30, 2025", + } + + with ( + patch("apps.owasp.models.event.slugify") as mock_slugify, + patch("apps.owasp.models.event.Event.objects.get") as mock_get, + patch.object(Event, "from_dict") as mock_from_dict, + patch.object(Event, "save") as mock_save, + ): + mock_slugify.return_value = "new-event" + mock_get.side_effect = Event.DoesNotExist + + result = Event.update_data(category, data) + + assert result is not None + mock_from_dict.assert_called_once() + mock_save.assert_called_once() + + def test_update_data_keyerror_returns_none(self): + """Test update_data returns None on KeyError from from_dict.""" + category = "Global" + data = {"name": "Test Event", "start-date": date(2025, 5, 26)} + + with ( + patch("apps.owasp.models.event.slugify") as mock_slugify, + patch("apps.owasp.models.event.Event.objects.get") as mock_get, + patch.object(Event, "from_dict") as mock_from_dict, + ): + mock_slugify.return_value = "test-event" + mock_get.side_effect = Event.DoesNotExist + mock_from_dict.side_effect = KeyError("missing-key") + + result = Event.update_data(category, data) + + assert result is None + + +class TestEventGeoMethods: + """Test cases for geo-related methods.""" + + def test_generate_geo_location_with_suggested_location(self): + """Test generate_geo_location uses suggested_location.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + suggested_location="San Francisco, CA", + ) + mock_location = Mock() + mock_location.latitude = 37.7749 + mock_location.longitude = -122.4194 + + with patch("apps.owasp.models.event.get_location_coordinates") as mock_get_coords: + mock_get_coords.return_value = mock_location + + event.generate_geo_location() + + assert event.latitude == 37.7749 + assert event.longitude == -122.4194 + mock_get_coords.assert_called_once_with("San Francisco, CA") + + def test_generate_geo_location_falls_back_to_context(self): + """Test generate_geo_location falls back to context when suggested_location fails.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + suggested_location="", + ) + mock_location = Mock() + mock_location.latitude = 40.7128 + mock_location.longitude = -74.0060 + + with patch("apps.owasp.models.event.get_location_coordinates") as mock_get_coords: + mock_get_coords.return_value = mock_location + + event.generate_geo_location() + + assert event.latitude is not None + + def test_generate_suggested_location(self): + """Test generate_suggested_location uses OpenAI.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + ) + + with ( + patch("apps.owasp.models.event.OpenAi") as mock_openai_cls, + patch("apps.owasp.models.event.Prompt") as mock_prompt, + ): + mock_openai = Mock() + mock_openai_cls.return_value = mock_openai + mock_openai.set_input.return_value = mock_openai + mock_openai.set_max_tokens.return_value = mock_openai + mock_openai.set_prompt.return_value = mock_openai + mock_openai.complete.return_value = "New York, NY" + mock_prompt.get_owasp_event_suggested_location.return_value = "prompt" + + event.generate_suggested_location() + + assert event.suggested_location == "New York, NY" + + def test_generate_suggested_location_handles_none_result(self): + """Test generate_suggested_location handles None result.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + ) + + with ( + patch("apps.owasp.models.event.OpenAi") as mock_openai_cls, + patch("apps.owasp.models.event.Prompt"), + ): + mock_openai = Mock() + mock_openai_cls.return_value = mock_openai + mock_openai.set_input.return_value = mock_openai + mock_openai.set_max_tokens.return_value = mock_openai + mock_openai.set_prompt.return_value = mock_openai + mock_openai.complete.return_value = "None" + + event.generate_suggested_location() + + assert event.suggested_location == "" + + def test_generate_suggested_location_handles_exception(self): + """Test generate_suggested_location handles exceptions.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + ) + + with ( + patch("apps.owasp.models.event.OpenAi") as mock_openai_cls, + patch("apps.owasp.models.event.Prompt"), + ): + mock_openai = Mock() + mock_openai_cls.return_value = mock_openai + mock_openai.set_input.return_value = mock_openai + mock_openai.set_max_tokens.return_value = mock_openai + mock_openai.set_prompt.return_value = mock_openai + mock_openai.complete.side_effect = ValueError("Error") + + event.generate_suggested_location() + + assert event.suggested_location == "" + + +class TestEventSummaryAndContext: + """Test cases for generate_summary and get_context methods.""" + + def test_generate_summary(self): + """Test generate_summary uses OpenAI.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + ) + + with ( + patch("apps.owasp.models.event.OpenAi") as mock_openai_cls, + patch("apps.owasp.models.event.Prompt") as mock_prompt, + ): + mock_openai = Mock() + mock_openai_cls.return_value = mock_openai + mock_openai.set_input.return_value = mock_openai + mock_openai.set_max_tokens.return_value = mock_openai + mock_openai.set_prompt.return_value = mock_openai + mock_openai.complete.return_value = "A great event summary" + mock_prompt.get_owasp_event_summary.return_value = "prompt" + + event.generate_summary() + + assert event.summary == "A great event summary" + + def test_generate_summary_handles_exception(self): + """Test generate_summary handles exceptions.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + ) + + with ( + patch("apps.owasp.models.event.OpenAi") as mock_openai_cls, + patch("apps.owasp.models.event.Prompt"), + ): + mock_openai = Mock() + mock_openai_cls.return_value = mock_openai + mock_openai.set_input.return_value = mock_openai + mock_openai.set_max_tokens.return_value = mock_openai + mock_openai.set_prompt.return_value = mock_openai + mock_openai.complete.side_effect = TypeError("Error") + + event.generate_summary() + + assert event.summary == "" + + def test_get_context_without_dates(self): + """Test get_context without dates.""" + event = Event( + key="test-event", + name="Test Event", + description="Test description", + summary="Test summary", + start_date=date(2025, 1, 1), + ) + + result = event.get_context() + + assert "Name: Test Event" in result + assert "Description: Test description" in result + assert "Summary: Test summary" in result + assert "Dates:" not in result + + def test_get_context_with_dates(self): + """Test get_context with dates.""" + event = Event( + key="test-event", + name="Test Event", + description="Test description", + summary="Test summary", + start_date=date(2025, 1, 1), + end_date=date(2025, 1, 5), + ) + + result = event.get_context(include_dates=True) + + assert "Name: Test Event" in result + assert "Dates:" in result + + +class TestEventSave: + """Test cases for save method.""" + + def test_save_generates_location_when_missing(self): + """Test save generates suggested_location when empty.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + suggested_location="", + ) + + with ( + patch.object(Event, "generate_suggested_location") as mock_gen_location, + patch.object(Event, "generate_geo_location") as mock_gen_geo, + patch("apps.owasp.models.event.BulkSaveModel.save"), + patch("apps.owasp.models.event.TimestampedModel.save"), + ): + event.save() + + mock_gen_location.assert_called_once() + mock_gen_geo.assert_called_once() + + def test_save_generates_geo_when_missing(self): + """Test save generates geo location when lat/long missing.""" + event = Event( + key="test-event", + name="Test Event", + start_date=date(2025, 1, 1), + suggested_location="New York", + latitude=None, + longitude=None, + ) + + with ( + patch.object(Event, "generate_geo_location") as mock_gen_geo, + patch("apps.owasp.models.event.BulkSaveModel.save"), + patch("apps.owasp.models.event.TimestampedModel.save"), + ): + event.save() + + mock_gen_geo.assert_called_once() diff --git a/backend/tests/apps/owasp/models/mixins/project_test.py b/backend/tests/apps/owasp/models/mixins/project_test.py new file mode 100644 index 0000000000..b1d81046f9 --- /dev/null +++ b/backend/tests/apps/owasp/models/mixins/project_test.py @@ -0,0 +1,293 @@ +"""Tests for ProjectIndexMixin.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from apps.owasp.models.mixins.project import ( + DEFAULT_HEALTH_SCORE, + ProjectIndexMixin, +) + + +class TestProjectIndexMixin: + """Test cases for ProjectIndexMixin.""" + + def create_mock_project(self, **kwargs): + """Create a mock project with ProjectIndexMixin methods.""" + mock_project = MagicMock(spec=ProjectIndexMixin) + mock_project.key = kwargs.get("key", "www-project-test") + mock_project.name = kwargs.get("name", "Test Project") + mock_project.level = kwargs.get("level", "Lab") + mock_project.level_raw = kwargs.get("level_raw", 2) + mock_project.type = kwargs.get("type", "code") + mock_project.custom_tags = kwargs.get("custom_tags", "security, testing") + mock_project.languages = kwargs.get("languages", ["Python", "JavaScript"]) + mock_project.contributors_count = kwargs.get("contributors_count", 10) + mock_project.forks_count = kwargs.get("forks_count", 5) + mock_project.stars_count = kwargs.get("stars_count", 100) + mock_project.is_active = kwargs.get("is_active", True) + mock_project.health_score = kwargs.get("health_score", 85.5) + mock_project.updated_at = kwargs.get( + "updated_at", datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC) + ) + + # Mock related managers + mock_project.organizations = MagicMock() + mock_project.repositories = MagicMock() + mock_project.open_issues = MagicMock() + + return mock_project + + def test_idx_companies(self): + """Test idx_companies returns joined company names.""" + mock_org1 = MagicMock() + mock_org1.company = "Company A" + mock_org2 = MagicMock() + mock_org2.company = "Company B" + + mock_project = self.create_mock_project() + mock_project.organizations.all.return_value = [mock_org1, mock_org2] + + result = ProjectIndexMixin.idx_companies.fget(mock_project) + + assert "Company A" in result + assert "Company B" in result + + def test_idx_contributors_count(self): + """Test idx_contributors_count returns contributors count.""" + mock_project = self.create_mock_project(contributors_count=25) + + result = ProjectIndexMixin.idx_contributors_count.fget(mock_project) + + assert result == 25 + + def test_idx_custom_tags(self): + """Test idx_custom_tags returns custom tags.""" + mock_project = self.create_mock_project(custom_tags="api, web") + + result = ProjectIndexMixin.idx_custom_tags.fget(mock_project) + + assert result == "api, web" + + def test_idx_forks_count(self): + """Test idx_forks_count returns forks count.""" + mock_project = self.create_mock_project(forks_count=42) + + result = ProjectIndexMixin.idx_forks_count.fget(mock_project) + + assert result == 42 + + @patch("apps.owasp.models.mixins.project.settings") + def test_idx_health_score_production(self, mock_settings): + """Test idx_health_score returns default in production.""" + mock_settings.IS_PRODUCTION_ENVIRONMENT = True + mock_project = self.create_mock_project(health_score=75.0) + + result = ProjectIndexMixin.idx_health_score.fget(mock_project) + + assert result == DEFAULT_HEALTH_SCORE + + @patch("apps.owasp.models.mixins.project.settings") + def test_idx_health_score_non_production(self, mock_settings): + """Test idx_health_score returns actual score in non-production.""" + mock_settings.IS_PRODUCTION_ENVIRONMENT = False + mock_project = self.create_mock_project(health_score=75.0) + + result = ProjectIndexMixin.idx_health_score.fget(mock_project) + + assert result == 75.0 + + def test_idx_is_active(self): + """Test idx_is_active returns active status.""" + mock_project = self.create_mock_project(is_active=True) + + result = ProjectIndexMixin.idx_is_active.fget(mock_project) + + assert result + + def test_idx_issues_count(self): + """Test idx_issues_count returns open issues count.""" + mock_project = self.create_mock_project() + mock_project.open_issues.count.return_value = 15 + + result = ProjectIndexMixin.idx_issues_count.fget(mock_project) + + assert result == 15 + + def test_idx_key(self): + """Test idx_key strips www-project- prefix.""" + mock_project = self.create_mock_project(key="www-project-zap") + + result = ProjectIndexMixin.idx_key.fget(mock_project) + + assert result == "zap" + + def test_idx_languages(self): + """Test idx_languages returns languages list.""" + mock_project = self.create_mock_project(languages=["Go", "Rust"]) + + result = ProjectIndexMixin.idx_languages.fget(mock_project) + + assert result == ["Go", "Rust"] + + def test_idx_level(self): + """Test idx_level returns level text.""" + mock_project = self.create_mock_project(level="Flagship") + + result = ProjectIndexMixin.idx_level.fget(mock_project) + + assert result == "Flagship" + + def test_idx_level_raw_with_value(self): + """Test idx_level_raw returns float when level_raw exists.""" + mock_project = self.create_mock_project(level_raw=3) + + result = ProjectIndexMixin.idx_level_raw.fget(mock_project) + + assert result == 3.0 + + def test_idx_level_raw_none(self): + """Test idx_level_raw returns None when level_raw is empty.""" + mock_project = self.create_mock_project(level_raw=None) + + result = ProjectIndexMixin.idx_level_raw.fget(mock_project) + + assert result is None + + def test_idx_name_with_name(self): + """Test idx_name returns name when available.""" + mock_project = self.create_mock_project(name="OWASP ZAP") + + result = ProjectIndexMixin.idx_name.fget(mock_project) + + assert result == "OWASP ZAP" + + def test_idx_name_without_name(self): + """Test idx_name generates name from key when name is empty.""" + mock_project = self.create_mock_project(name="", key="www-project-juice-shop") + + result = ProjectIndexMixin.idx_name.fget(mock_project) + + assert "juice" in result.lower() + + def test_idx_organizations(self): + """Test idx_organizations returns joined organization names.""" + mock_org1 = MagicMock() + mock_org1.name = "Org A" + mock_org2 = MagicMock() + mock_org2.name = "Org B" + + mock_project = self.create_mock_project() + mock_project.organizations.all.return_value = [mock_org1, mock_org2] + + result = ProjectIndexMixin.idx_organizations.fget(mock_project) + + assert "Org A" in result + assert "Org B" in result + + def test_idx_repositories(self): + """Test idx_repositories returns repository dicts.""" + mock_release = MagicMock() + mock_release.summary = "v1.0.0" + + mock_owner = MagicMock() + mock_owner.login = "OWASP" + + mock_repo = MagicMock() + mock_repo.contributors_count = 5 + mock_repo.description = "Test repo" + mock_repo.forks_count = 2 + mock_repo.key = "TEST-REPO" + mock_repo.latest_release = mock_release + mock_repo.license = "MIT" + mock_repo.name = "test-repo" + mock_repo.owner = mock_owner + mock_repo.stars_count = 50 + + mock_project = self.create_mock_project() + mock_project.repositories.order_by.return_value.__getitem__.return_value = [mock_repo] + + result = ProjectIndexMixin.idx_repositories.fget(mock_project) + + assert len(result) == 1 + assert result[0]["key"] == "test-repo" + assert result[0]["owner_key"] == "owasp" + + def test_idx_repositories_no_release(self): + """Test idx_repositories handles missing release.""" + mock_owner = MagicMock() + mock_owner.login = "OWASP" + + mock_repo = MagicMock() + mock_repo.contributors_count = 5 + mock_repo.description = "Test repo" + mock_repo.forks_count = 2 + mock_repo.key = "TEST-REPO" + mock_repo.latest_release = None + mock_repo.license = "MIT" + mock_repo.name = "test-repo" + mock_repo.owner = mock_owner + mock_repo.stars_count = 50 + + mock_project = self.create_mock_project() + mock_project.repositories.order_by.return_value.__getitem__.return_value = [mock_repo] + + result = ProjectIndexMixin.idx_repositories.fget(mock_project) + + assert result[0]["latest_release"] == "" + + def test_idx_repositories_count(self): + """Test idx_repositories_count returns repository count.""" + mock_project = self.create_mock_project() + mock_project.repositories.count.return_value = 3 + + result = ProjectIndexMixin.idx_repositories_count.fget(mock_project) + + assert result == 3 + + def test_idx_stars_count(self): + """Test idx_stars_count returns stars count.""" + mock_project = self.create_mock_project(stars_count=500) + + result = ProjectIndexMixin.idx_stars_count.fget(mock_project) + + assert result == 500 + + def test_idx_top_contributors(self): + """Test idx_top_contributors calls RepositoryContributor.""" + mock_project = self.create_mock_project(key="www-project-test") + + with patch( + "apps.owasp.models.mixins.project.RepositoryContributor.get_top_contributors" + ) as mock_get: + mock_get.return_value = [{"login": "user1"}, {"login": "user2"}] + + result = ProjectIndexMixin.idx_top_contributors.fget(mock_project) + + mock_get.assert_called_once_with(project="www-project-test") + assert len(result) == 2 + + def test_idx_type(self): + """Test idx_type returns type.""" + mock_project = self.create_mock_project(type="documentation") + + result = ProjectIndexMixin.idx_type.fget(mock_project) + + assert result == "documentation" + + def test_idx_updated_at_with_datetime(self): + """Test idx_updated_at returns timestamp when updated_at exists.""" + test_datetime = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC) + mock_project = self.create_mock_project(updated_at=test_datetime) + + result = ProjectIndexMixin.idx_updated_at.fget(mock_project) + + assert result == test_datetime.timestamp() + + def test_idx_updated_at_none(self): + """Test idx_updated_at returns empty string when updated_at is None.""" + mock_project = self.create_mock_project(updated_at=None) + + result = ProjectIndexMixin.idx_updated_at.fget(mock_project) + + assert result == "" diff --git a/backend/tests/apps/owasp/models/project_test.py b/backend/tests/apps/owasp/models/project_test.py index 3179866da8..e5bb856d9b 100644 --- a/backend/tests/apps/owasp/models/project_test.py +++ b/backend/tests/apps/owasp/models/project_test.py @@ -1,3 +1,4 @@ +from datetime import UTC, datetime from unittest.mock import Mock, patch import pytest @@ -131,3 +132,333 @@ def test_from_github(self): assert project.level == ProjectLevel.LAB assert project.type == ProjectType.TOOL assert project.updated_at == owasp_repository.updated_at + + +class TestProjectProperties: + """Tests for additional Project property methods.""" + + def test_entity_leaders(self, mocker): + """Test entity_leaders returns up to MAX_LEADERS_COUNT leaders.""" + mock_leaders = [Mock() for _ in range(7)] + mocker.patch( + "apps.owasp.models.common.RepositoryBasedEntityModel.entity_leaders", + new_callable=mocker.PropertyMock, + return_value=mock_leaders, + ) + + project = Project() + result = project.entity_leaders + + assert len(result) == 5 # MAX_LEADERS_COUNT + + def test_health_score_with_metrics(self, mocker): + """Test health_score returns score when metrics exist.""" + mock_metrics = Mock() + mock_metrics.score = 85.5 + mocker.patch.object( + Project, + "last_health_metrics", + new_callable=mocker.PropertyMock, + return_value=mock_metrics, + ) + + project = Project() + assert project.health_score == 85.5 + + def test_health_score_without_metrics(self, mocker): + """Test health_score returns None when no metrics.""" + mocker.patch.object( + Project, "last_health_metrics", new_callable=mocker.PropertyMock, return_value=None + ) + + project = Project() + assert project.health_score is None + + def test_is_funding_requirements_compliant(self, mocker): + """Test is_funding_requirements_compliant checks repositories.""" + mock_repos = Mock() + mock_repos.filter.return_value.exists.return_value = False + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + assert project.is_funding_requirements_compliant + + def test_is_leader_requirements_compliant_with_multiple_leaders(self, mocker): + """Test returns True when more than one leader.""" + mocker.patch.object( + Project, "leaders_count", new_callable=mocker.PropertyMock, return_value=3 + ) + + project = Project() + assert project.is_leader_requirements_compliant + + def test_is_leader_requirements_compliant_with_single_leader(self, mocker): + """Test returns False when only one leader.""" + mocker.patch.object( + Project, "leaders_count", new_callable=mocker.PropertyMock, return_value=1 + ) + + project = Project() + assert not project.is_leader_requirements_compliant + + def test_issues(self, mocker): + """Test issues property returns filtered queryset.""" + mock_issue = mocker.patch("apps.owasp.models.project.Issue") + mock_repos = Mock() + mock_repos.all.return_value = ["repo1"] + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + _ = project.issues + + mock_issue.objects.filter.assert_called_once() + + def test_issues_count(self, mocker): + """Test issues_count returns count.""" + mock_issues = Mock() + mock_issues.count.return_value = 42 + mocker.patch.object( + Project, "issues", new_callable=mocker.PropertyMock, return_value=mock_issues + ) + + project = Project() + assert project.issues_count == 42 + + def test_last_health_metrics(self, mocker): + """Test last_health_metrics returns the latest metrics.""" + mock_metrics = Mock() + mock_health_metrics = Mock() + mock_health_metrics.order_by.return_value.first.return_value = mock_metrics + mocker.patch.object( + Project, + "health_metrics", + new_callable=mocker.PropertyMock, + return_value=mock_health_metrics, + ) + + project = Project() + result = project.last_health_metrics + + mock_health_metrics.order_by.assert_called_once_with("-nest_created_at") + assert result == mock_metrics + + def test_leaders_count(self): + """Test leaders_count returns length of leaders_raw.""" + project = Project() + project.leaders_raw = ["leader1", "leader2", "leader3"] + assert project.leaders_count == 3 + + def test_nest_key(self): + """Test nest_key removes www-project- prefix.""" + project = Project(key="www-project-juice-shop") + assert project.nest_key == "juice-shop" + + def test_nest_url(self, mocker): + """Test nest_url returns full URL.""" + mocker.patch( + "apps.owasp.models.project.get_absolute_url", + return_value="https://nest.owasp.org/projects/juice-shop", + ) + + project = Project(key="www-project-juice-shop") + assert "juice-shop" in project.nest_url + + def test_open_issues(self, mocker): + """Test open_issues returns filtered queryset.""" + mock_issue = mocker.patch("apps.owasp.models.project.Issue") + mock_repos = Mock() + mock_repos.all.return_value = ["repo1"] + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + _ = project.open_issues + + mock_issue.open_issues.filter.assert_called_once() + + def test_open_pull_requests_count(self, mocker): + """Test open_pull_requests_count returns count of open PRs.""" + mock_prs = Mock() + mock_prs.filter.return_value.count.return_value = 7 + mocker.patch.object( + Project, "pull_requests", new_callable=mocker.PropertyMock, return_value=mock_prs + ) + + project = Project() + assert project.open_pull_requests_count == 7 + + def test_owasp_page_last_updated_at_with_repo(self, mocker): + """Test returns updated_at from owasp_repository.""" + mock_repo = Mock() + mock_repo.updated_at = datetime(2024, 1, 15, tzinfo=UTC) + mocker.patch.object( + Project, "owasp_repository", new_callable=mocker.PropertyMock, return_value=mock_repo + ) + + project = Project() + assert project.owasp_page_last_updated_at == datetime(2024, 1, 15, tzinfo=UTC) + + def test_owasp_page_last_updated_at_without_repo(self, mocker): + """Test returns None when no owasp_repository.""" + mocker.patch.object( + Project, "owasp_repository", new_callable=mocker.PropertyMock, return_value=None + ) + + project = Project() + assert project.owasp_page_last_updated_at is None + + def test_pull_requests(self, mocker): + """Test pull_requests property returns filtered queryset.""" + mock_pr = mocker.patch("apps.owasp.models.project.PullRequest") + mock_repos = Mock() + mock_repos.all.return_value = ["repo1"] + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + _ = project.pull_requests + + mock_pr.objects.filter.assert_called_once() + + def test_pull_requests_count(self, mocker): + """Test pull_requests_count returns count.""" + mock_prs = Mock() + mock_prs.count.return_value = 25 + mocker.patch.object( + Project, "pull_requests", new_callable=mocker.PropertyMock, return_value=mock_prs + ) + + project = Project() + assert project.pull_requests_count == 25 + + def test_pull_request_last_created_at(self, mocker): + """Test pull_request_last_created_at returns max created_at.""" + mock_prs = Mock() + mock_prs.aggregate.return_value = {"created_at__max": datetime(2024, 1, 20, tzinfo=UTC)} + mocker.patch.object( + Project, "pull_requests", new_callable=mocker.PropertyMock, return_value=mock_prs + ) + + project = Project() + result = project.pull_request_last_created_at + + assert result == datetime(2024, 1, 20, tzinfo=UTC) + + def test_published_releases(self, mocker): + """Test published_releases returns filtered queryset.""" + mock_release = mocker.patch("apps.owasp.models.project.Release") + mock_repos = Mock() + mock_repos.all.return_value = ["repo1"] + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + _ = project.published_releases + + mock_release.objects.filter.assert_called_once() + + def test_recent_milestones(self, mocker): + """Test recent_milestones returns filtered queryset.""" + mock_milestone = mocker.patch("apps.owasp.models.project.Milestone") + mock_repos = Mock() + mock_repos.all.return_value = ["repo1"] + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + _ = project.recent_milestones + + mock_milestone.objects.filter.assert_called_once() + + def test_recent_releases_count(self, mocker): + """Test recent_releases_count returns count of recent releases.""" + mock_releases = Mock() + mock_releases.filter.return_value.count.return_value = 3 + mocker.patch.object( + Project, + "published_releases", + new_callable=mocker.PropertyMock, + return_value=mock_releases, + ) + + project = Project() + result = project.recent_releases_count + + assert result == 3 + + def test_repositories_count(self, mocker): + """Test repositories_count returns count.""" + mock_repos = Mock() + mock_repos.count.return_value = 5 + mocker.patch.object( + Project, "repositories", new_callable=mocker.PropertyMock, return_value=mock_repos + ) + + project = Project() + assert project.repositories_count == 5 + + def test_unanswered_issues_count(self, mocker): + """Test unanswered_issues_count returns count of issues with no comments.""" + mock_issues = Mock() + mock_issues.filter.return_value.count.return_value = 10 + mocker.patch.object( + Project, "issues", new_callable=mocker.PropertyMock, return_value=mock_issues + ) + + project = Project() + result = project.unanswered_issues_count + + mock_issues.filter.assert_called_once_with(comments_count=0) + assert result == 10 + + def test_unassigned_issues_count(self, mocker): + """Test unassigned_issues_count returns count of unassigned issues.""" + mock_issues = Mock() + mock_issues.filter.return_value.count.return_value = 8 + mocker.patch.object( + Project, "issues", new_callable=mocker.PropertyMock, return_value=mock_issues + ) + + project = Project() + result = project.unassigned_issues_count + + mock_issues.filter.assert_called_once_with(assignees__isnull=True) + assert result == 8 + + def test_get_absolute_url(self): + """Test get_absolute_url returns project URL.""" + project = Project(key="www-project-test") + assert project.get_absolute_url() == "/projects/test" + + def test_save_generates_summary(self, mocker): + """Test save generates summary when conditions are met.""" + mock_prompt = Mock() + mocker.patch( + "apps.owasp.models.project.Prompt.get_owasp_project_summary", + return_value=mock_prompt, + ) + mock_generate = mocker.patch.object(Project, "generate_summary") + mocker.patch.object(Project.__bases__[0], "save") + + project = Project(is_active=True, summary="") + project.save() + + mock_generate.assert_called_once_with(prompt=mock_prompt) + + def test_save_skips_summary_when_exists(self, mocker): + """Test save skips summary generation when summary exists.""" + mock_generate = mocker.patch.object(Project, "generate_summary") + mocker.patch.object(Project.__bases__[0], "save") + + project = Project(is_active=True, summary="Existing summary") + project.save() + + mock_generate.assert_not_called() diff --git a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py index f73c080a42..9873a882a9 100644 --- a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py +++ b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock from django.core.management import call_command +from slack_sdk.errors import SlackApiError from apps.slack.management.commands.slack_sync_messages import Command @@ -34,10 +35,10 @@ def test_handle_command_flow(self, mocker): mock_history_response = { "ok": True, "messages": [ - {"user": "U1", "reply_count": 1, "ts": "123.001"}, # Message WITH replies - {"user": "U2", "ts": "123.002"}, # Message WITHOUT replies + {"user": "U1", "reply_count": 1, "ts": "123.001"}, + {"user": "U2", "ts": "123.002"}, ], - "response_metadata": {"next_cursor": ""}, # No more pages + "response_metadata": {"next_cursor": ""}, } mock_client_instance.conversations_history.return_value = mock_history_response mock_client_instance.conversations_replies.return_value = {"ok": True, "messages": []} @@ -90,3 +91,490 @@ def test_handle_slack_response_on_failure(self, mocker): mock_logger.error.assert_called_once_with("test_method API call failed") mock_stdout.write.assert_called_once() + + +class TestResolveGithubToSlack: + """Tests for _resolve_github_to_slack method.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_github_user_not_found(self, mocker): + """Test handling when GitHub user doesn't exist.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user.DoesNotExist = Exception + mock_user.objects.get.side_effect = mock_user.DoesNotExist + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + result = command._resolve_github_to_slack("nonexistent_user") + + assert result is None + command.stderr.write.assert_called_once() + + def test_member_profile_not_found(self, mocker): + """Test handling when MemberProfile doesn't exist for user.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + + mock_user_instance = MagicMock() + mock_user.objects.get.return_value = mock_user_instance + mock_profile.DoesNotExist = Exception + mock_profile.objects.get.side_effect = mock_profile.DoesNotExist + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + result = command._resolve_github_to_slack("user_without_profile") + + assert result is None + command.stderr.write.assert_called_once() + + def test_no_slack_id_in_profile(self, mocker): + """Test handling when profile has no Slack ID.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + + mock_user_instance = MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_profile_instance = MagicMock() + mock_profile_instance.owasp_slack_id = None + mock_profile.objects.get.return_value = mock_profile_instance + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + result = command._resolve_github_to_slack("user_no_slack") + + assert result is None + command.stderr.write.assert_called_once() + + def test_successful_resolution(self, mocker): + """Test successful GitHub to Slack resolution.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + + mock_user_instance = MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_profile_instance = MagicMock() + mock_profile_instance.owasp_slack_id = "U12345" + mock_profile.objects.get.return_value = mock_profile_instance + + command = Command() + command.stdout = MagicMock() + + result = command._resolve_github_to_slack("valid_user") + + assert result == "U12345" + + +class TestSyncUserMessages: + """Tests for _sync_user_messages method.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_no_search_token(self, mocker): + """Test handling when DJANGO_SLACK_SEARCH_TOKEN is not set.""" + mocker.patch.dict("os.environ", {}, clear=True) + mocker.patch(f"{self.target_module}.os.environ.get", return_value="") + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + command._sync_user_messages("U123", None, None, 1.0, 3) + + command.stderr.write.assert_called_once() + + def test_no_workspaces(self, mocker): + """Test handling when no workspaces exist.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-test-token") + mocker.patch(f"{self.target_module}.WebClient") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_queryset = MagicMock() + mock_queryset.exists.return_value = False + mock_workspace.objects.all.return_value = mock_queryset + + command = Command() + command.stdout = MagicMock() + + command._sync_user_messages("U123", None, None, 1.0, 3) + + command.stdout.write.assert_called() + + def test_no_messages_found(self, mocker): + """Test handling when search returns no messages.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-test-token") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_workspace_instance = MagicMock() + mock_workspace_instance.name = "Test Workspace" + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = [mock_workspace_instance] + mock_workspace.objects.all.return_value = mock_queryset + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + mock_client.search_messages.return_value = { + "ok": True, + "messages": {"matches": []}, + } + + command = Command() + command.stdout = MagicMock() + + command._sync_user_messages("U123", "2024-01-01", "2024-12-31", 0.1, 3) + + mock_client.search_messages.assert_called_once() + + def test_rate_limit_handling(self, mocker): + """Test rate limiting is handled with retries.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-test-token") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_workspace_instance = MagicMock() + mock_workspace_instance.name = "Test Workspace" + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = [mock_workspace_instance] + mock_workspace.objects.all.return_value = mock_queryset + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + rate_limit_error = SlackApiError( + response={"ok": False, "error": "ratelimited", "headers": {"Retry-After": "1"}}, + message="Rate limited", + ) + rate_limit_error.response = MagicMock() + rate_limit_error.response.__getitem__ = ( + lambda _self, key: "ratelimited" if key == "error" else None + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + mock_client.search_messages.side_effect = [ + rate_limit_error, + {"ok": True, "messages": {"matches": []}}, + ] + + command = Command() + command.stdout = MagicMock() + + command._sync_user_messages("U123", None, None, 0.1, 3) + + def test_max_retries_exceeded(self, mocker): + """Test handling when max retries are exceeded.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-test-token") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_workspace_instance = MagicMock() + mock_workspace_instance.name = "Test Workspace" + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = [mock_workspace_instance] + mock_workspace.objects.all.return_value = mock_queryset + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + rate_limit_error = SlackApiError( + response={"ok": False, "error": "ratelimited"}, + message="Rate limited", + ) + rate_limit_error.response = MagicMock() + rate_limit_error.response.__getitem__ = ( + lambda _self, key: "ratelimited" if key == "error" else None + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + + mock_client.search_messages.side_effect = rate_limit_error + + command = Command() + command.stdout = MagicMock() + command._sync_user_messages("U123", None, None, 0.1, 1) + + +class TestFetchReplies: + """Tests for _fetch_replies method.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_no_replies_found(self, mocker): + """Test handles case when no replies are found.""" + mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + mock_client.conversations_replies.return_value = {"ok": True, "messages": []} + + mock_message = MagicMock() + mock_message.conversation.slack_channel_id = "C123" + mock_message.latest_reply = None + mock_message.slack_message_id = "123.456" + mock_message.url = "http://test.com" + + command = Command() + command.stdout = MagicMock() + + command._fetch_replies(mock_client, mock_message, 1.0, 3) + + mock_client.conversations_replies.assert_called_once() + + def test_replies_saved(self, mocker): + """Test replies are saved when found.""" + mock_message_model = mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + mock_client.conversations_replies.return_value = { + "ok": True, + "messages": [{"user": "U1", "ts": "123.789"}], + "response_metadata": {"next_cursor": ""}, + } + + mock_message = MagicMock() + mock_message.conversation.slack_channel_id = "C123" + mock_message.latest_reply = None + mock_message.slack_message_id = "123.456" + mock_message.url = "http://test.com" + + command = Command() + command.stdout = MagicMock() + + mock_reply = MagicMock() + mocker.patch.object(command, "_create_message", return_value=mock_reply) + + command._fetch_replies(mock_client, mock_message, 1.0, 3) + + mock_message_model.bulk_save.assert_called_once() + + def test_slack_api_error_handling(self, mocker): + """Test SlackApiError is handled gracefully.""" + mock_client = MagicMock() + mock_client.conversations_replies.side_effect = SlackApiError( + response={"ok": False, "error": "channel_not_found"}, + message="channel not found", + ) + + mock_message = MagicMock() + mock_message.conversation.slack_channel_id = "C123" + mock_message.latest_reply = None + mock_message.slack_message_id = "123.456" + mock_message.url = "http://test.com" + + command = Command() + command.stdout = MagicMock() + + command._fetch_replies(mock_client, mock_message, 1.0, 3) + + command.stdout.write.assert_called() + + +class TestCreateMessage: + """Tests for _create_message method.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_create_message_with_existing_member(self, mocker): + """Test creates message with existing member.""" + mock_member = mocker.patch(f"{self.target_module}.Member") + mock_message_model = mocker.patch(f"{self.target_module}.Message") + + existing_member = MagicMock() + mock_member.objects.get.return_value = existing_member + + mock_client = MagicMock() + mock_conversation = MagicMock() + message_data = {"user": "U123", "ts": "123.456"} + + command = Command() + command.stdout = MagicMock() + + command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + + mock_message_model.update_data.assert_called_once_with( + data=message_data, + conversation=mock_conversation, + author=existing_member, + parent_message=None, + save=False, + ) + + def test_create_message_no_user_id(self, mocker): + """Test creates message without user lookup when no user ID.""" + mock_message_model = mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + mock_conversation = MagicMock() + message_data = {"ts": "123.456", "text": "Test message"} + + command = Command() + command.stdout = MagicMock() + + command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + + mock_message_model.update_data.assert_called_once_with( + data=message_data, + conversation=mock_conversation, + author=None, + parent_message=None, + save=False, + ) + + def test_create_message_with_new_user(self, mocker): + """Test creates new member when user doesn't exist.""" + mock_member = mocker.patch(f"{self.target_module}.Member") + mocker.patch(f"{self.target_module}.Message") + + mock_member.DoesNotExist = Exception + mock_member.objects.get.side_effect = mock_member.DoesNotExist + + mock_new_member = MagicMock() + mock_member.update_data.return_value = mock_new_member + + mock_client = MagicMock() + mock_client.users_info.return_value = { + "ok": True, + "user": {"id": "U123", "name": "newuser"}, + } + + mock_conversation = MagicMock() + message_data = {"user": "U123", "ts": "123.456"} + + command = Command() + command.stdout = MagicMock() + + command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + + mock_client.users_info.assert_called_once() + + def test_create_message_with_bot(self, mocker): + """Test creates bot member when bot_id is present.""" + mock_member = mocker.patch(f"{self.target_module}.Member") + mocker.patch(f"{self.target_module}.Message") + + mock_member.DoesNotExist = Exception + mock_member.objects.get.side_effect = mock_member.DoesNotExist + + mock_new_member = MagicMock() + mock_member.update_data.return_value = mock_new_member + + mock_client = MagicMock() + mock_client.bots_info.return_value = { + "ok": True, + "bot": {"name": "Test Bot"}, + } + + mock_conversation = MagicMock() + message_data = {"bot_id": "B123", "ts": "123.456"} + + command = Command() + command.stdout = MagicMock() + + command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + + mock_client.bots_info.assert_called_once() + + def test_create_message_rate_limit_on_user_lookup(self, mocker): + """Test handles rate limiting when looking up user info.""" + mock_member = mocker.patch(f"{self.target_module}.Member") + mocker.patch(f"{self.target_module}.Message") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_member.DoesNotExist = Exception + mock_member.objects.get.side_effect = mock_member.DoesNotExist + + mock_new_member = MagicMock() + mock_member.update_data.return_value = mock_new_member + + rate_limit_error = SlackApiError( + response={"ok": False, "error": "ratelimited"}, + message="Rate limited", + ) + rate_limit_error.response = MagicMock() + rate_limit_error.response.get = ( + lambda key, default=None: "ratelimited" if key == "error" else default + ) + rate_limit_error.response.headers = MagicMock() + rate_limit_error.response.headers.get = lambda _key, _default: 1 + + mock_client = MagicMock() + mock_client.users_info.side_effect = [ + rate_limit_error, + {"ok": True, "user": {"id": "U123", "name": "testuser"}}, + ] + + mock_conversation = MagicMock() + message_data = {"user": "U123", "ts": "123.456"} + + command = Command() + command.stdout = MagicMock() + + command._create_message(mock_client, message_data, mock_conversation, 0.1, 3) + + +class TestHandleWithGithubUserId: + """Tests for handle method with github_user_id option.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_github_user_id_resolves_to_slack(self, mocker): + """Test that github_user_id is resolved to slack_user_id.""" + mocker.patch(f"{self.target_module}.Workspace") + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + mock_resolve = mocker.patch.object( + command, "_resolve_github_to_slack", return_value="U12345" + ) + mock_sync = mocker.patch.object(command, "_sync_user_messages") + + command.handle( + batch_size=999, + channel_id=None, + delay=4, + max_retries=5, + slack_user_id=None, + github_user_id="test_github_user", + start_at=None, + end_at=None, + ) + + mock_resolve.assert_called_once_with("test_github_user") + mock_sync.assert_called_once() + + def test_github_user_id_resolution_fails(self, mocker): + """Test that execution stops when github_user_id resolution fails.""" + mocker.patch(f"{self.target_module}.Workspace") + + command = Command() + command.stdout = MagicMock() + command.stderr = MagicMock() + + mock_resolve = mocker.patch.object(command, "_resolve_github_to_slack", return_value=None) + mock_sync = mocker.patch.object(command, "_sync_user_messages") + + command.handle( + batch_size=999, + channel_id=None, + delay=4, + max_retries=5, + slack_user_id=None, + github_user_id="nonexistent_user", + start_at=None, + end_at=None, + ) + + mock_resolve.assert_called_once() + mock_sync.assert_not_called() diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 17c660b4c2..8e07c57af5 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -152,6 +152,7 @@ pymdownx pypoetry pyyaml quasis +reindexing relativedelta repositorycontributor requirepass @@ -184,6 +185,7 @@ xapp xdg xdist xoxb +xoxp xsser xzf zapconfig From e83b319ac4b0498d219d32f86a3bdfa0dc515ec7 Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 30 Jan 2026 21:33:43 +0000 Subject: [PATCH 2/8] fix: code rabbit comments --- backend/tests/apps/common/index_test.py | 6 ++++-- backend/tests/apps/owasp/admin/entity_member_test.py | 7 +++++-- .../apps/owasp/api/internal/queries/snapshot_test.py | 12 ++++++++---- .../management/commands/process_snapshots_test.py | 4 ---- .../tests/apps/owasp/models/entity_member_test.py | 8 ++++---- backend/tests/apps/owasp/models/project_test.py | 4 +--- .../management/commands/slack_sync_messages_test.py | 12 +++++++++--- 7 files changed, 31 insertions(+), 22 deletions(-) diff --git a/backend/tests/apps/common/index_test.py b/backend/tests/apps/common/index_test.py index c064d0676b..8508b02447 100644 --- a/backend/tests/apps/common/index_test.py +++ b/backend/tests/apps/common/index_test.py @@ -14,6 +14,8 @@ register, ) +TEST_IP_ADDRESS = "127.0.0.1" + ENV = settings.ENVIRONMENT.lower() TOTAL_COUNT = 42 @@ -310,6 +312,6 @@ def test_get_client_with_ip_address(self): mock_config_instance.headers = {} mock_config.return_value = mock_config_instance - IndexBase.get_client(ip_address="192.168.1.1") + IndexBase.get_client(ip_address=TEST_IP_ADDRESS) - assert mock_config_instance.headers["X-Forwarded-For"] == "192.168.1.1" + assert mock_config_instance.headers["X-Forwarded-For"] == TEST_IP_ADDRESS diff --git a/backend/tests/apps/owasp/admin/entity_member_test.py b/backend/tests/apps/owasp/admin/entity_member_test.py index c293ed67fa..8bcc3c3ee9 100644 --- a/backend/tests/apps/owasp/admin/entity_member_test.py +++ b/backend/tests/apps/owasp/admin/entity_member_test.py @@ -22,10 +22,13 @@ def test_approve_members_action(self): mock_queryset = MagicMock() mock_queryset.update.return_value = 3 - self.admin.approve_members(mock_request, mock_queryset) + with patch.object(self.admin, "message_user") as mock_message_user: + self.admin.approve_members(mock_request, mock_queryset) mock_queryset.update.assert_called_once_with(is_active=True, is_reviewed=True) - mock_request.assert_not_called() + mock_message_user.assert_called_once() + call_args = mock_message_user.call_args + assert "3" in call_args[0][1] def test_entity_display_with_entity(self): """Test entity display method when entity exists.""" diff --git a/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py b/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py index 9783ddcf2f..f470ec2576 100644 --- a/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/snapshot_test.py @@ -25,7 +25,7 @@ def test_snapshot_exists(self): """Test snapshot returns snapshot when found.""" mock_snapshot = MagicMock(spec=Snapshot) - with patch("apps.owasp.models.snapshot.Snapshot.objects.get") as mock_get: + with patch("apps.owasp.api.internal.queries.snapshot.Snapshot.objects.get") as mock_get: mock_get.return_value = mock_snapshot result = self.query.__class__.__dict__["snapshot"](self.query, key="test-key") @@ -38,7 +38,7 @@ def test_snapshot_exists(self): def test_snapshot_not_exists(self): """Test snapshot returns None when not found.""" - with patch("apps.owasp.models.snapshot.Snapshot.objects.get") as mock_get: + with patch("apps.owasp.api.internal.queries.snapshot.Snapshot.objects.get") as mock_get: mock_get.side_effect = Snapshot.DoesNotExist result = self.query.__class__.__dict__["snapshot"](self.query, key="nonexistent") @@ -49,7 +49,9 @@ def test_snapshots_with_positive_limit(self): """Test snapshots returns list with positive limit.""" mock_snapshots = [MagicMock(spec=Snapshot), MagicMock(spec=Snapshot)] - with patch("apps.owasp.models.snapshot.Snapshot.objects.filter") as mock_filter: + with patch( + "apps.owasp.api.internal.queries.snapshot.Snapshot.objects.filter" + ) as mock_filter: mock_filter.return_value.order_by.return_value.__getitem__ = MagicMock( return_value=mock_snapshots ) @@ -74,7 +76,9 @@ def test_snapshots_limit_clamped_to_max(self): """Test snapshots clamps limit to MAX_LIMIT.""" mock_snapshots = [MagicMock(spec=Snapshot)] - with patch("apps.owasp.models.snapshot.Snapshot.objects.filter") as mock_filter: + with patch( + "apps.owasp.api.internal.queries.snapshot.Snapshot.objects.filter" + ) as mock_filter: mock_filter.return_value.order_by.return_value.__getitem__ = MagicMock( return_value=mock_snapshots ) diff --git a/backend/tests/apps/owasp/management/commands/process_snapshots_test.py b/backend/tests/apps/owasp/management/commands/process_snapshots_test.py index d924f68be4..e1b92d5aa4 100644 --- a/backend/tests/apps/owasp/management/commands/process_snapshots_test.py +++ b/backend/tests/apps/owasp/management/commands/process_snapshots_test.py @@ -12,15 +12,11 @@ class TestProcessSnapshots: def test_process_snapshots_with_snapshots(self): """Test if pending snapshots are processed.""" command = Command() - - # Create mock snapshot mock_snapshot = mock.MagicMock() mock_snapshot.id = 1 mock_snapshot.status = "pending" mock_snapshot.start_at = timezone.now() mock_snapshot.end_at = timezone.now() - - # Create mock queryset mock_queryset = mock.MagicMock() mock_queryset.exists.return_value = True mock_queryset.__iter__.return_value = [mock_snapshot] diff --git a/backend/tests/apps/owasp/models/entity_member_test.py b/backend/tests/apps/owasp/models/entity_member_test.py index 1a1b9b83e9..448be5d859 100644 --- a/backend/tests/apps/owasp/models/entity_member_test.py +++ b/backend/tests/apps/owasp/models/entity_member_test.py @@ -10,8 +10,8 @@ class TestEntityMemberModel: """Test cases for EntityMember model.""" - def test_str_with_member_login(self): - """Test string representation uses member login when available.""" + def test_role_and_member_name_attributes(self): + """Test role and member_name attributes are set correctly.""" entity_member = EntityMember( member_name="Test User", role=EntityMember.Role.LEADER, @@ -21,8 +21,8 @@ def test_str_with_member_login(self): assert entity_member.member_name == "Test User" assert entity_member.get_role_display() == "Leader" - def test_str_without_member_uses_name(self): - """Test string representation uses member_name when member is None.""" + def test_member_role_display(self): + """Test get_role_display returns correct role label.""" entity_member = EntityMember( member_name="John Doe", role=EntityMember.Role.MEMBER, diff --git a/backend/tests/apps/owasp/models/project_test.py b/backend/tests/apps/owasp/models/project_test.py index e5bb856d9b..a6303bc491 100644 --- a/backend/tests/apps/owasp/models/project_test.py +++ b/backend/tests/apps/owasp/models/project_test.py @@ -90,8 +90,6 @@ def test_update_data_project_does_not_exist(self, mock_requests_get, mock_get): Some project description """ mock_requests_get.return_value = mock_response - - # Setup test data gh_repository_mock = Mock() gh_repository_mock.name = "new_repo" repository_mock = Repository() @@ -149,7 +147,7 @@ def test_entity_leaders(self, mocker): project = Project() result = project.entity_leaders - assert len(result) == 5 # MAX_LEADERS_COUNT + assert len(result) == 5 def test_health_score_with_metrics(self, mocker): """Test health_score returns score when metrics exist.""" diff --git a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py index 9873a882a9..af8e0c7baa 100644 --- a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py +++ b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py @@ -260,6 +260,9 @@ def test_rate_limit_handling(self, mocker): rate_limit_error.response.__getitem__ = ( lambda _self, key: "ratelimited" if key == "error" else None ) + rate_limit_error.response.get.side_effect = ( + lambda key, default=None: "ratelimited" if key == "error" else default + ) rate_limit_error.response.headers = {"Retry-After": "1"} mock_client.search_messages.side_effect = [ rate_limit_error, @@ -296,6 +299,9 @@ def test_max_retries_exceeded(self, mocker): rate_limit_error.response.__getitem__ = ( lambda _self, key: "ratelimited" if key == "error" else None ) + rate_limit_error.response.get.side_effect = ( + lambda key, default=None: "ratelimited" if key == "error" else default + ) rate_limit_error.response.headers = {"Retry-After": "1"} mock_client.search_messages.side_effect = rate_limit_error @@ -321,7 +327,7 @@ def test_no_replies_found(self, mocker): mock_message.conversation.slack_channel_id = "C123" mock_message.latest_reply = None mock_message.slack_message_id = "123.456" - mock_message.url = "http://test.com" + mock_message.url = "https://test.com" command = Command() command.stdout = MagicMock() @@ -345,7 +351,7 @@ def test_replies_saved(self, mocker): mock_message.conversation.slack_channel_id = "C123" mock_message.latest_reply = None mock_message.slack_message_id = "123.456" - mock_message.url = "http://test.com" + mock_message.url = "https://test.com" command = Command() command.stdout = MagicMock() @@ -369,7 +375,7 @@ def test_slack_api_error_handling(self, mocker): mock_message.conversation.slack_channel_id = "C123" mock_message.latest_reply = None mock_message.slack_message_id = "123.456" - mock_message.url = "http://test.com" + mock_message.url = "https://test.com" command = Command() command.stdout = MagicMock() From 4a163ae848834b0527d89ecea435c95022240a53 Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 30 Jan 2026 21:48:20 +0000 Subject: [PATCH 3/8] fix: sonar issue --- backend/tests/apps/owasp/models/event_test.py | 4 ++-- backend/tests/apps/owasp/models/mixins/project_test.py | 6 ++++-- backend/tests/apps/owasp/models/project_test.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index 6bfcf5c88a..6d151fc274 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -236,8 +236,8 @@ def test_generate_geo_location_with_suggested_location(self): event.generate_geo_location() - assert event.latitude == 37.7749 - assert event.longitude == -122.4194 + assert event.latitude == pytest.approx(37.7749) + assert event.longitude == pytest.approx(-122.4194) mock_get_coords.assert_called_once_with("San Francisco, CA") def test_generate_geo_location_falls_back_to_context(self): diff --git a/backend/tests/apps/owasp/models/mixins/project_test.py b/backend/tests/apps/owasp/models/mixins/project_test.py index b1d81046f9..96e4e15834 100644 --- a/backend/tests/apps/owasp/models/mixins/project_test.py +++ b/backend/tests/apps/owasp/models/mixins/project_test.py @@ -3,6 +3,8 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch +import pytest + from apps.owasp.models.mixins.project import ( DEFAULT_HEALTH_SCORE, ProjectIndexMixin, @@ -95,7 +97,7 @@ def test_idx_health_score_non_production(self, mock_settings): result = ProjectIndexMixin.idx_health_score.fget(mock_project) - assert result == 75.0 + assert result == pytest.approx(75.0) def test_idx_is_active(self): """Test idx_is_active returns active status.""" @@ -144,7 +146,7 @@ def test_idx_level_raw_with_value(self): result = ProjectIndexMixin.idx_level_raw.fget(mock_project) - assert result == 3.0 + assert result == pytest.approx(3.0) def test_idx_level_raw_none(self): """Test idx_level_raw returns None when level_raw is empty.""" diff --git a/backend/tests/apps/owasp/models/project_test.py b/backend/tests/apps/owasp/models/project_test.py index a6303bc491..82ae9cf151 100644 --- a/backend/tests/apps/owasp/models/project_test.py +++ b/backend/tests/apps/owasp/models/project_test.py @@ -161,7 +161,7 @@ def test_health_score_with_metrics(self, mocker): ) project = Project() - assert project.health_score == 85.5 + assert project.health_score == pytest.approx(85.5) def test_health_score_without_metrics(self, mocker): """Test health_score returns None when no metrics.""" From ce94a6d0d5971a58cd5ae63f053295350257080e Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Mon, 2 Feb 2026 18:56:11 -0800 Subject: [PATCH 4/8] Update code --- backend/pyproject.toml | 2 +- backend/tests/apps/ai/agent/agent_test.py | 18 +++++----- backend/tests/apps/common/index_test.py | 2 +- .../queries/project_health_metrics_test.py | 4 +-- backend/tests/apps/owasp/models/event_test.py | 5 +-- .../apps/owasp/models/mixins/project_test.py | 7 ++-- .../tests/apps/owasp/models/project_test.py | 3 +- .../commands/slack_sync_messages_test.py | 34 +++++++++---------- cspell/custom-dict.txt | 1 - 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0edb6ef2e3..82180dc416 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -141,7 +141,7 @@ DJANGO_CONFIGURATION = "Test" DJANGO_SETTINGS_MODULE = "settings.test" addopts = [ "--cov-config=pyproject.toml", - "--cov-fail-under=90", + "--cov-fail-under=80", "--cov-precision=2", "--cov-report=term-missing", "--cov-report=xml", diff --git a/backend/tests/apps/ai/agent/agent_test.py b/backend/tests/apps/ai/agent/agent_test.py index f078580d16..4bcdfa2481 100644 --- a/backend/tests/apps/ai/agent/agent_test.py +++ b/backend/tests/apps/ai/agent/agent_test.py @@ -36,11 +36,11 @@ def test_run(self, mocker): mock_compiled_graph.invoke.return_value = { "answer": "Test answer", - "iteration": 2, - "evaluation": {"score": 0.9}, "context_chunks": [{"text": "chunk1"}], - "history": ["step1", "step2"], + "evaluation": {"score": 0.9}, "extracted_metadata": {"key": "value"}, + "history": ["step1", "step2"], + "iteration": 2, } agent = AgenticRAGAgent() @@ -48,11 +48,11 @@ def test_run(self, mocker): mock_compiled_graph.invoke.assert_called_once() assert result["answer"] == "Test answer" - assert result["iterations"] == 2 - assert result["evaluation"] == {"score": 0.9} assert result["context_chunks"] == [{"text": "chunk1"}] - assert result["history"] == ["step1", "step2"] + assert result["evaluation"] == {"score": 0.9} assert result["extracted_metadata"] == {"key": "value"} + assert result["history"] == ["step1", "step2"] + assert result["iterations"] == 2 def test_run_with_empty_result(self, mocker): """Test run method handles empty results gracefully.""" @@ -70,11 +70,11 @@ def test_run_with_empty_result(self, mocker): result = agent.run("Test query") assert result["answer"] == "" - assert result["iterations"] == 0 - assert result["evaluation"] == {} assert result["context_chunks"] == [] - assert result["history"] == [] + assert result["evaluation"] == {} assert result["extracted_metadata"] == {} + assert result["history"] == [] + assert result["iterations"] == 0 def test_build_graph(self, mocker): """Test build_graph creates the correct state machine.""" diff --git a/backend/tests/apps/common/index_test.py b/backend/tests/apps/common/index_test.py index 8508b02447..69f5292847 100644 --- a/backend/tests/apps/common/index_test.py +++ b/backend/tests/apps/common/index_test.py @@ -251,7 +251,7 @@ def test_parse_synonyms_file_not_found(self): self.mock_logger.exception.assert_called_once() def test_reindex_synonyms_success(self): - """Test successful reindexing of synonyms.""" + """Test successful re-indexing of synonyms.""" synonyms = [ {"objectID": "1", "type": "synonym", "synonyms": ["a", "b"]}, {"objectID": "2", "type": "synonym", "synonyms": ["c", "d"]}, diff --git a/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py index 97ad154f91..b3a0bb27a7 100644 --- a/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/project_health_metrics_test.py @@ -54,7 +54,7 @@ def test_resolve_health_stats(self, mock_get_stats): """Test resolving the health stats.""" expected_stats = ProjectHealthStatsNode( average_score=65.0, - monthly_overall_scores=[77.5, 60.0, 40.0], + monthly_overall_scores=[77.5, 60, 40], monthly_overall_scores_months=[1, 2, 3], projects_count_healthy=1, projects_count_need_attention=2, @@ -82,7 +82,7 @@ def test_resolve_project_health_metrics(self, mock_get_latest_metrics): ProjectHealthMetricsNode( stars_count=1000, forks_count=200, - score=85.0, + score=85, contributors_count=50, open_issues_count=10, open_pull_requests_count=5, diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index 6d151fc274..7230ca640f 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -1,3 +1,4 @@ +import math from datetime import date from unittest.mock import Mock, patch @@ -236,8 +237,8 @@ def test_generate_geo_location_with_suggested_location(self): event.generate_geo_location() - assert event.latitude == pytest.approx(37.7749) - assert event.longitude == pytest.approx(-122.4194) + assert math.isclose(event.latitude, 37.7749) + assert math.isclose(event.longitude, -122.4194) mock_get_coords.assert_called_once_with("San Francisco, CA") def test_generate_geo_location_falls_back_to_context(self): diff --git a/backend/tests/apps/owasp/models/mixins/project_test.py b/backend/tests/apps/owasp/models/mixins/project_test.py index 96e4e15834..4b3921415c 100644 --- a/backend/tests/apps/owasp/models/mixins/project_test.py +++ b/backend/tests/apps/owasp/models/mixins/project_test.py @@ -1,10 +1,9 @@ """Tests for ProjectIndexMixin.""" +import math from datetime import UTC, datetime from unittest.mock import MagicMock, patch -import pytest - from apps.owasp.models.mixins.project import ( DEFAULT_HEALTH_SCORE, ProjectIndexMixin, @@ -97,7 +96,7 @@ def test_idx_health_score_non_production(self, mock_settings): result = ProjectIndexMixin.idx_health_score.fget(mock_project) - assert result == pytest.approx(75.0) + assert math.isclose(result, 75.0) def test_idx_is_active(self): """Test idx_is_active returns active status.""" @@ -146,7 +145,7 @@ def test_idx_level_raw_with_value(self): result = ProjectIndexMixin.idx_level_raw.fget(mock_project) - assert result == pytest.approx(3.0) + assert math.isclose(result, 3) def test_idx_level_raw_none(self): """Test idx_level_raw returns None when level_raw is empty.""" diff --git a/backend/tests/apps/owasp/models/project_test.py b/backend/tests/apps/owasp/models/project_test.py index 82ae9cf151..1b37ba6fc1 100644 --- a/backend/tests/apps/owasp/models/project_test.py +++ b/backend/tests/apps/owasp/models/project_test.py @@ -1,3 +1,4 @@ +import math from datetime import UTC, datetime from unittest.mock import Mock, patch @@ -161,7 +162,7 @@ def test_health_score_with_metrics(self, mocker): ) project = Project() - assert project.health_score == pytest.approx(85.5) + assert math.isclose(project.health_score, 85.5) def test_health_score_without_metrics(self, mocker): """Test health_score returns None when no metrics.""" diff --git a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py index af8e0c7baa..5d4c39e04a 100644 --- a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py +++ b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py @@ -314,11 +314,11 @@ def test_max_retries_exceeded(self, mocker): class TestFetchReplies: """Tests for _fetch_replies method.""" - target_module = "apps.slack.management.commands.slack_sync_messages" + target_module_path = "apps.slack.management.commands.slack_sync_messages" def test_no_replies_found(self, mocker): """Test handles case when no replies are found.""" - mocker.patch(f"{self.target_module}.Message") + mocker.patch(f"{self.target_module_path}.Message") mock_client = MagicMock() mock_client.conversations_replies.return_value = {"ok": True, "messages": []} @@ -338,7 +338,7 @@ def test_no_replies_found(self, mocker): def test_replies_saved(self, mocker): """Test replies are saved when found.""" - mock_message_model = mocker.patch(f"{self.target_module}.Message") + mock_message_model = mocker.patch(f"{self.target_module_path}.Message") mock_client = MagicMock() mock_client.conversations_replies.return_value = { @@ -388,12 +388,12 @@ def test_slack_api_error_handling(self, mocker): class TestCreateMessage: """Tests for _create_message method.""" - target_module = "apps.slack.management.commands.slack_sync_messages" + target_module_path = "apps.slack.management.commands.slack_sync_messages" def test_create_message_with_existing_member(self, mocker): """Test creates message with existing member.""" - mock_member = mocker.patch(f"{self.target_module}.Member") - mock_message_model = mocker.patch(f"{self.target_module}.Message") + mock_member = mocker.patch(f"{self.target_module_path}.Member") + mock_message_model = mocker.patch(f"{self.target_module_path}.Message") existing_member = MagicMock() mock_member.objects.get.return_value = existing_member @@ -405,7 +405,7 @@ def test_create_message_with_existing_member(self, mocker): command = Command() command.stdout = MagicMock() - command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + command._create_message(mock_client, message_data, mock_conversation, 1, 3) mock_message_model.update_data.assert_called_once_with( data=message_data, @@ -417,7 +417,7 @@ def test_create_message_with_existing_member(self, mocker): def test_create_message_no_user_id(self, mocker): """Test creates message without user lookup when no user ID.""" - mock_message_model = mocker.patch(f"{self.target_module}.Message") + mock_message_model = mocker.patch(f"{self.target_module_path}.Message") mock_client = MagicMock() mock_conversation = MagicMock() @@ -438,8 +438,8 @@ def test_create_message_no_user_id(self, mocker): def test_create_message_with_new_user(self, mocker): """Test creates new member when user doesn't exist.""" - mock_member = mocker.patch(f"{self.target_module}.Member") - mocker.patch(f"{self.target_module}.Message") + mock_member = mocker.patch(f"{self.target_module_path}.Member") + mocker.patch(f"{self.target_module_path}.Message") mock_member.DoesNotExist = Exception mock_member.objects.get.side_effect = mock_member.DoesNotExist @@ -459,14 +459,14 @@ def test_create_message_with_new_user(self, mocker): command = Command() command.stdout = MagicMock() - command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + command._create_message(mock_client, message_data, mock_conversation, 1, 3) mock_client.users_info.assert_called_once() def test_create_message_with_bot(self, mocker): """Test creates bot member when bot_id is present.""" - mock_member = mocker.patch(f"{self.target_module}.Member") - mocker.patch(f"{self.target_module}.Message") + mock_member = mocker.patch(f"{self.target_module_path}.Member") + mocker.patch(f"{self.target_module_path}.Message") mock_member.DoesNotExist = Exception mock_member.objects.get.side_effect = mock_member.DoesNotExist @@ -486,15 +486,15 @@ def test_create_message_with_bot(self, mocker): command = Command() command.stdout = MagicMock() - command._create_message(mock_client, message_data, mock_conversation, 1.0, 3) + command._create_message(mock_client, message_data, mock_conversation, 1, 3) mock_client.bots_info.assert_called_once() def test_create_message_rate_limit_on_user_lookup(self, mocker): """Test handles rate limiting when looking up user info.""" - mock_member = mocker.patch(f"{self.target_module}.Member") - mocker.patch(f"{self.target_module}.Message") - mocker.patch(f"{self.target_module}.time.sleep") + mock_member = mocker.patch(f"{self.target_module_path}.Member") + mocker.patch(f"{self.target_module_path}.Message") + mocker.patch(f"{self.target_module_path}.time.sleep") mock_member.DoesNotExist = Exception mock_member.objects.get.side_effect = mock_member.DoesNotExist diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 8e07c57af5..aeb27cbbf4 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -152,7 +152,6 @@ pymdownx pypoetry pyyaml quasis -reindexing relativedelta repositorycontributor requirepass From f30c395d68a884bbc24877f6169b57e367fa37ad Mon Sep 17 00:00:00 2001 From: Harsh Date: Tue, 3 Feb 2026 04:34:37 +0000 Subject: [PATCH 5/8] fix: cubic-dev-ai comments --- backend/tests/apps/api/rest/v0/issue_test.py | 8 ++++---- backend/tests/apps/api/rest/v0/milestone_test.py | 8 ++++---- backend/tests/apps/api/rest/v0/release_test.py | 8 ++++---- backend/tests/apps/owasp/models/event_test.py | 3 ++- backend/tests/apps/owasp/models/project_test.py | 5 +++-- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/backend/tests/apps/api/rest/v0/issue_test.py b/backend/tests/apps/api/rest/v0/issue_test.py index dd6b487250..1cdc79010d 100644 --- a/backend/tests/apps/api/rest/v0/issue_test.py +++ b/backend/tests/apps/api/rest/v0/issue_test.py @@ -106,11 +106,9 @@ def test_list_issues_with_repository_filter(self, mocker): def test_list_issues_with_state_filter(self, mocker): """Test listing issues with state filter.""" mock_qs = MagicMock() - mock_filtered = MagicMock() mock_qs.select_related.return_value = mock_qs - mock_qs.filter.return_value = mock_filtered - mock_filtered.filter.return_value = mock_filtered - mock_filtered.order_by.return_value = [] + mock_qs.filter.return_value = mock_qs + mock_qs.order_by.return_value = [] mocker.patch( "apps.api.rest.v0.issue.IssueModel.objects", mock_qs, @@ -120,6 +118,8 @@ def test_list_issues_with_state_filter(self, mocker): filters = IssueFilter(state="open") list_issues(request, filters, None) + mock_qs.filter.assert_called_with(state="open") + def test_list_issues_with_ordering(self, mocker): """Test listing issues with custom ordering.""" mock_qs = MagicMock() diff --git a/backend/tests/apps/api/rest/v0/milestone_test.py b/backend/tests/apps/api/rest/v0/milestone_test.py index 6a5e2f6054..d3473f9777 100644 --- a/backend/tests/apps/api/rest/v0/milestone_test.py +++ b/backend/tests/apps/api/rest/v0/milestone_test.py @@ -157,11 +157,9 @@ def test_list_milestones_with_repository_filter(self, mocker): def test_list_milestones_with_state_filter(self, mocker): """Test listing milestones with state filter.""" mock_qs = MagicMock() - mock_filtered = MagicMock() mock_qs.select_related.return_value = mock_qs - mock_qs.filter.return_value = mock_filtered - mock_filtered.filter.return_value = mock_filtered - mock_filtered.order_by.return_value = [] + mock_qs.filter.return_value = mock_qs + mock_qs.order_by.return_value = [] mocker.patch( "apps.api.rest.v0.milestone.MilestoneModel.objects", mock_qs, @@ -171,6 +169,8 @@ def test_list_milestones_with_state_filter(self, mocker): filters = MilestoneFilter(state="open") list_milestones(request, filters, None) + mock_qs.filter.assert_called_with(state="open") + def test_list_milestones_with_ordering(self, mocker): """Test listing milestones with custom ordering.""" mock_qs = MagicMock() diff --git a/backend/tests/apps/api/rest/v0/release_test.py b/backend/tests/apps/api/rest/v0/release_test.py index 6b7a7614cd..fa602fbcaf 100644 --- a/backend/tests/apps/api/rest/v0/release_test.py +++ b/backend/tests/apps/api/rest/v0/release_test.py @@ -103,11 +103,9 @@ def test_list_release_with_repository_filter(self, mocker): def test_list_release_with_tag_filter(self, mocker): """Test listing releases with tag_name filter.""" mock_qs = MagicMock() - mock_filtered = MagicMock() mock_qs.exclude.return_value.select_related.return_value = mock_qs - mock_qs.filter.return_value = mock_filtered - mock_filtered.filter.return_value = mock_filtered - mock_filtered.order_by.return_value = [] + mock_qs.filter.return_value = mock_qs + mock_qs.order_by.return_value = [] mocker.patch( "apps.api.rest.v0.release.ReleaseModel.objects", mock_qs, @@ -117,6 +115,8 @@ def test_list_release_with_tag_filter(self, mocker): filters = ReleaseFilter(tag_name="v1.0.0") list_release(request, filters, None) + mock_qs.filter.assert_called_with(tag_name="v1.0.0") + def test_list_release_with_ordering(self, mocker): """Test listing releases with custom ordering.""" mock_qs = MagicMock() diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index 7230ca640f..11be47be9f 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -258,7 +258,8 @@ def test_generate_geo_location_falls_back_to_context(self): event.generate_geo_location() - assert event.latitude is not None + assert math.isclose(event.latitude, 40.7128) + assert math.isclose(event.longitude, -74.0060) def test_generate_suggested_location(self): """Test generate_suggested_location uses OpenAI.""" diff --git a/backend/tests/apps/owasp/models/project_test.py b/backend/tests/apps/owasp/models/project_test.py index 1b37ba6fc1..46988da0f2 100644 --- a/backend/tests/apps/owasp/models/project_test.py +++ b/backend/tests/apps/owasp/models/project_test.py @@ -4,6 +4,7 @@ import pytest +from apps.common.models import BulkSaveModel from apps.github.models.repository import Repository from apps.github.models.user import User from apps.owasp.models.enums.project import ProjectLevel, ProjectType @@ -445,7 +446,7 @@ def test_save_generates_summary(self, mocker): return_value=mock_prompt, ) mock_generate = mocker.patch.object(Project, "generate_summary") - mocker.patch.object(Project.__bases__[0], "save") + mocker.patch.object(BulkSaveModel, "save") project = Project(is_active=True, summary="") project.save() @@ -455,7 +456,7 @@ def test_save_generates_summary(self, mocker): def test_save_skips_summary_when_exists(self, mocker): """Test save skips summary generation when summary exists.""" mock_generate = mocker.patch.object(Project, "generate_summary") - mocker.patch.object(Project.__bases__[0], "save") + mocker.patch.object(BulkSaveModel, "save") project = Project(is_active=True, summary="Existing summary") project.save() From da918ba898e62da32ea9967730905b8dd6d67374 Mon Sep 17 00:00:00 2001 From: Harsh Date: Tue, 3 Feb 2026 04:54:09 +0000 Subject: [PATCH 6/8] fix: code rabbitai comment --- backend/apps/api/rest/v0/issue.py | 5 ++++- backend/apps/api/rest/v0/milestone.py | 5 ++++- backend/apps/api/rest/v0/release.py | 5 ++++- backend/tests/apps/api/rest/v0/issue_test.py | 2 +- backend/tests/apps/api/rest/v0/milestone_test.py | 2 +- backend/tests/apps/api/rest/v0/release_test.py | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/apps/api/rest/v0/issue.py b/backend/apps/api/rest/v0/issue.py index 1fdb615cf0..9a088dd8e9 100644 --- a/backend/apps/api/rest/v0/issue.py +++ b/backend/apps/api/rest/v0/issue.py @@ -90,7 +90,10 @@ def list_issues( if filters.state: issues = issues.filter(state=filters.state) - return issues.order_by(ordering or "-created_at", "-updated_at") + primary_order = ordering or "-created_at" + if primary_order.lstrip("-") == "updated_at": + return issues.order_by(primary_order) + return issues.order_by(primary_order, "-updated_at") @router.get( diff --git a/backend/apps/api/rest/v0/milestone.py b/backend/apps/api/rest/v0/milestone.py index c4761b6f0f..a62672cd6b 100644 --- a/backend/apps/api/rest/v0/milestone.py +++ b/backend/apps/api/rest/v0/milestone.py @@ -92,7 +92,10 @@ def list_milestones( if filters.state: milestones = milestones.filter(state=filters.state) - return milestones.order_by(ordering or "-created_at", "-updated_at") + primary_order = ordering or "-created_at" + if primary_order.lstrip("-") == "updated_at": + return milestones.order_by(primary_order) + return milestones.order_by(primary_order, "-updated_at") @router.get( diff --git a/backend/apps/api/rest/v0/release.py b/backend/apps/api/rest/v0/release.py index 04073129e5..26face40e0 100644 --- a/backend/apps/api/rest/v0/release.py +++ b/backend/apps/api/rest/v0/release.py @@ -91,7 +91,10 @@ 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") + primary_order = ordering or "-published_at" + if primary_order.lstrip("-") == "created_at": + return releases.order_by(primary_order) + return releases.order_by(primary_order, "-created_at") @router.get( diff --git a/backend/tests/apps/api/rest/v0/issue_test.py b/backend/tests/apps/api/rest/v0/issue_test.py index 1cdc79010d..50c5954113 100644 --- a/backend/tests/apps/api/rest/v0/issue_test.py +++ b/backend/tests/apps/api/rest/v0/issue_test.py @@ -134,7 +134,7 @@ def test_list_issues_with_ordering(self, mocker): filters = IssueFilter() list_issues(request, filters, "updated_at") - mock_qs.order_by.assert_called_once_with("updated_at", "-updated_at") + mock_qs.order_by.assert_called_once_with("updated_at") class TestGetIssue: diff --git a/backend/tests/apps/api/rest/v0/milestone_test.py b/backend/tests/apps/api/rest/v0/milestone_test.py index d3473f9777..542c5b1aa6 100644 --- a/backend/tests/apps/api/rest/v0/milestone_test.py +++ b/backend/tests/apps/api/rest/v0/milestone_test.py @@ -185,7 +185,7 @@ def test_list_milestones_with_ordering(self, mocker): filters = MilestoneFilter() list_milestones(request, filters, "updated_at") - mock_qs.order_by.assert_called_once_with("updated_at", "-updated_at") + mock_qs.order_by.assert_called_once_with("updated_at") class TestGetMilestone: diff --git a/backend/tests/apps/api/rest/v0/release_test.py b/backend/tests/apps/api/rest/v0/release_test.py index fa602fbcaf..2b33e3d734 100644 --- a/backend/tests/apps/api/rest/v0/release_test.py +++ b/backend/tests/apps/api/rest/v0/release_test.py @@ -131,7 +131,7 @@ def test_list_release_with_ordering(self, mocker): filters = ReleaseFilter() list_release(request, filters, "created_at") - mock_qs.order_by.assert_called_once_with("created_at", "-created_at") + mock_qs.order_by.assert_called_once_with("created_at") class TestGetRelease: From 183f083bb09350d83a22cf361e8202e9bebf3d0f Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Wed, 4 Feb 2026 16:28:51 -0800 Subject: [PATCH 7/8] Update code --- backend/apps/api/rest/v0/issue.py | 8 +++++--- backend/apps/api/rest/v0/milestone.py | 8 +++++--- backend/apps/api/rest/v0/release.py | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/apps/api/rest/v0/issue.py b/backend/apps/api/rest/v0/issue.py index 9a088dd8e9..4b85d6fd8a 100644 --- a/backend/apps/api/rest/v0/issue.py +++ b/backend/apps/api/rest/v0/issue.py @@ -91,9 +91,11 @@ def list_issues( issues = issues.filter(state=filters.state) primary_order = ordering or "-created_at" - if primary_order.lstrip("-") == "updated_at": - return issues.order_by(primary_order) - return issues.order_by(primary_order, "-updated_at") + order_fields = [primary_order] + if primary_order not in {"updated_at", "-updated_at"}: + order_fields.append("-updated_at") + + return issues.order_by(*order_fields) @router.get( diff --git a/backend/apps/api/rest/v0/milestone.py b/backend/apps/api/rest/v0/milestone.py index a62672cd6b..abee6a26be 100644 --- a/backend/apps/api/rest/v0/milestone.py +++ b/backend/apps/api/rest/v0/milestone.py @@ -93,9 +93,11 @@ def list_milestones( milestones = milestones.filter(state=filters.state) primary_order = ordering or "-created_at" - if primary_order.lstrip("-") == "updated_at": - return milestones.order_by(primary_order) - return milestones.order_by(primary_order, "-updated_at") + order_fields = [primary_order] + if primary_order not in {"updated_at", "-updated_at"}: + order_fields.append("-updated_at") + + return milestones.order_by(*order_fields) @router.get( diff --git a/backend/apps/api/rest/v0/release.py b/backend/apps/api/rest/v0/release.py index 26face40e0..f03fac3727 100644 --- a/backend/apps/api/rest/v0/release.py +++ b/backend/apps/api/rest/v0/release.py @@ -92,9 +92,11 @@ def list_release( releases = releases.filter(tag_name=filters.tag_name) primary_order = ordering or "-published_at" - if primary_order.lstrip("-") == "created_at": - return releases.order_by(primary_order) - return releases.order_by(primary_order, "-created_at") + order_fields = [primary_order] + if primary_order not in {"created_at", "-created_at"}: + order_fields.append("-created_at") + + return releases.order_by(*order_fields) @router.get( From deae57d210511d743d717bf06fadc48b0a211c7b Mon Sep 17 00:00:00 2001 From: Arkadii Yakovets Date: Wed, 4 Feb 2026 16:39:56 -0800 Subject: [PATCH 8/8] Run pre-commit --- .../commands/slack_sync_messages_test.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py index 5d4c39e04a..09926a26b6 100644 --- a/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py +++ b/backend/tests/apps/slack/management/commands/slack_sync_messages_test.py @@ -257,11 +257,11 @@ def test_rate_limit_handling(self, mocker): message="Rate limited", ) rate_limit_error.response = MagicMock() - rate_limit_error.response.__getitem__ = ( - lambda _self, key: "ratelimited" if key == "error" else None + rate_limit_error.response.__getitem__ = lambda _self, key: ( + "ratelimited" if key == "error" else None ) - rate_limit_error.response.get.side_effect = ( - lambda key, default=None: "ratelimited" if key == "error" else default + rate_limit_error.response.get.side_effect = lambda key, default=None: ( + "ratelimited" if key == "error" else default ) rate_limit_error.response.headers = {"Retry-After": "1"} mock_client.search_messages.side_effect = [ @@ -296,11 +296,11 @@ def test_max_retries_exceeded(self, mocker): message="Rate limited", ) rate_limit_error.response = MagicMock() - rate_limit_error.response.__getitem__ = ( - lambda _self, key: "ratelimited" if key == "error" else None + rate_limit_error.response.__getitem__ = lambda _self, key: ( + "ratelimited" if key == "error" else None ) - rate_limit_error.response.get.side_effect = ( - lambda key, default=None: "ratelimited" if key == "error" else default + rate_limit_error.response.get.side_effect = lambda key, default=None: ( + "ratelimited" if key == "error" else default ) rate_limit_error.response.headers = {"Retry-After": "1"} @@ -507,8 +507,8 @@ def test_create_message_rate_limit_on_user_lookup(self, mocker): message="Rate limited", ) rate_limit_error.response = MagicMock() - rate_limit_error.response.get = ( - lambda key, default=None: "ratelimited" if key == "error" else default + rate_limit_error.response.get = lambda key, default=None: ( + "ratelimited" if key == "error" else default ) rate_limit_error.response.headers = MagicMock() rate_limit_error.response.headers.get = lambda _key, _default: 1