diff --git a/backend/apps/owasp/management/commands/owasp_sync_posts.py b/backend/apps/owasp/management/commands/owasp_sync_posts.py index 083aab664c..9cbb6d5de1 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_posts.py +++ b/backend/apps/owasp/management/commands/owasp_sync_posts.py @@ -2,6 +2,7 @@ import json import re +from typing import Any import yaml import yaml.scanner @@ -77,6 +78,7 @@ def handle(self, *args, **options) -> None: if not post_content.startswith("---"): continue + metadata: dict[str, Any] = {} try: if match := yaml_pattern.search(post_content): metadata_yaml = match.group(1) diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index bbca0c2a23..cfbf815cff 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -238,11 +238,8 @@ def get_leaders_emails(self): ) for match in matches: - if match[0] and match[1]: # Name with email - leaders[match[0].strip()] = match[1].strip() - elif match[2]: # Name without email - leaders[match[2].strip()] = None - + name, value = match[0].strip(), match[1].strip() + leaders[name] = value if "@" in value else None return leaders def get_metadata(self): diff --git a/backend/tests/apps/api/decorators/cache_test.py b/backend/tests/apps/api/decorators/cache_test.py index abea081aa5..599d16f855 100644 --- a/backend/tests/apps/api/decorators/cache_test.py +++ b/backend/tests/apps/api/decorators/cache_test.py @@ -10,23 +10,24 @@ from apps.api.decorators.cache import cache_response, generate_key -@pytest.mark.parametrize( - ("full_path", "prefix", "expected_key"), - [ - ("/api/test", "p1", "p1:/api/test"), - ("/api/test?a=1", "p2", "p2:/api/test?a=1"), - ("/api/test?a=1&b=2", "p3", "p3:/api/test?a=1&b=2"), - ("/api/test?b=2&a=1", "p4", "p4:/api/test?b=2&a=1"), - ], -) -def test_generate_cache_key(full_path, prefix, expected_key): - """Test cases for the generate cache key function.""" - request = HttpRequest() - parsed_url = urlparse(full_path) - request.path = parsed_url.path - request.META["QUERY_STRING"] = parsed_url.query - - assert generate_key(request, prefix) == expected_key +class TestGenerateCacheKey: + @pytest.mark.parametrize( + ("full_path", "prefix", "expected_key"), + [ + ("/api/test", "p1", "p1:/api/test"), + ("/api/test?a=1", "p2", "p2:/api/test?a=1"), + ("/api/test?a=1&b=2", "p3", "p3:/api/test?a=1&b=2"), + ("/api/test?b=2&a=1", "p4", "p4:/api/test?b=2&a=1"), + ], + ) + def test_generate_cache_key(self, full_path, prefix, expected_key): + """Test cases for the generate cache key function.""" + request = HttpRequest() + parsed_url = urlparse(full_path) + request.path = parsed_url.path + request.META["QUERY_STRING"] = parsed_url.query + + assert generate_key(request, prefix) == expected_key class TestCacheResponse: diff --git a/backend/tests/apps/api/rest/v0/chapter_test.py b/backend/tests/apps/api/rest/v0/chapter_test.py index 4cdd7e01fd..22d6d2d8c2 100644 --- a/backend/tests/apps/api/rest/v0/chapter_test.py +++ b/backend/tests/apps/api/rest/v0/chapter_test.py @@ -7,66 +7,67 @@ from apps.api.rest.v0.chapter import ChapterDetail, get_chapter, list_chapters -@pytest.mark.parametrize( - "chapter_data", - [ - { - "country": "America", - "created_at": "2024-11-01T00:00:00Z", - "key": "nagoya", - "latitude": 35.1815, - "longitude": 136.9066, - "name": "OWASP Nagoya", - "region": "Europe", - "updated_at": "2024-07-02T00:00:00Z", - }, - { - "country": "India", - "created_at": "2023-12-01T00:00:00Z", - "key": "something", - "latitude": 12.9716, - "longitude": 77.5946, - "name": "OWASP something", - "region": "Asia", - "updated_at": "2023-09-02T00:00:00Z", - }, - ], -) -def test_chapter_serializer_validation(chapter_data): - class MockMember: - def __init__(self, login): - self.login = login - - class MockEntityMember: - def __init__(self, name, login=None): - self.member = MockMember(login) if login else None - self.member_name = name - - class MockChapter: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - self.nest_key = data["key"] - self.entity_leaders = [ - MockEntityMember("Alice", "alice"), - MockEntityMember("Bob"), - ] - - chapter = ChapterDetail.from_orm(MockChapter(chapter_data)) - - assert chapter.country == chapter_data["country"] - assert chapter.created_at == datetime.fromisoformat(chapter_data["created_at"]) - assert chapter.key == chapter_data["key"] - assert chapter.latitude == chapter_data["latitude"] - assert chapter.longitude == chapter_data["longitude"] - assert len(chapter.leaders) == 2 - assert chapter.leaders[0].key == "alice" - assert chapter.leaders[0].name == "Alice" - assert chapter.leaders[1].key is None - assert chapter.leaders[1].name == "Bob" - assert chapter.name == chapter_data["name"] - assert chapter.region == chapter_data["region"] - assert chapter.updated_at == datetime.fromisoformat(chapter_data["updated_at"]) +class TestChapterSerializerValidation: + @pytest.mark.parametrize( + "chapter_data", + [ + { + "country": "America", + "created_at": "2024-11-01T00:00:00Z", + "key": "nagoya", + "latitude": 35.1815, + "longitude": 136.9066, + "name": "OWASP Nagoya", + "region": "Europe", + "updated_at": "2024-07-02T00:00:00Z", + }, + { + "country": "India", + "created_at": "2023-12-01T00:00:00Z", + "key": "something", + "latitude": 12.9716, + "longitude": 77.5946, + "name": "OWASP something", + "region": "Asia", + "updated_at": "2023-09-02T00:00:00Z", + }, + ], + ) + def test_chapter_serializer_validation(self, chapter_data): + class MockMember: + def __init__(self, login): + self.login = login + + class MockEntityMember: + def __init__(self, name, login=None): + self.member = MockMember(login) if login else None + self.member_name = name + + class MockChapter: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + self.entity_leaders = [ + MockEntityMember("Alice", "alice"), + MockEntityMember("Bob"), + ] + + chapter = ChapterDetail.from_orm(MockChapter(chapter_data)) + + assert chapter.country == chapter_data["country"] + assert chapter.created_at == datetime.fromisoformat(chapter_data["created_at"]) + assert chapter.key == chapter_data["key"] + assert chapter.latitude == chapter_data["latitude"] + assert chapter.longitude == chapter_data["longitude"] + assert len(chapter.leaders) == 2 + assert chapter.leaders[0].key == "alice" + assert chapter.leaders[0].name == "Alice" + assert chapter.leaders[1].key is None + assert chapter.leaders[1].name == "Bob" + assert chapter.name == chapter_data["name"] + assert chapter.region == chapter_data["region"] + assert chapter.updated_at == datetime.fromisoformat(chapter_data["updated_at"]) class TestListChapters: diff --git a/backend/tests/apps/api/rest/v0/committee_test.py b/backend/tests/apps/api/rest/v0/committee_test.py index aa15a753c3..f847e5fb00 100644 --- a/backend/tests/apps/api/rest/v0/committee_test.py +++ b/backend/tests/apps/api/rest/v0/committee_test.py @@ -7,39 +7,40 @@ from apps.api.rest.v0.committee import CommitteeDetail, get_committee, list_committees -@pytest.mark.parametrize( - "committee_data", - [ - { - "created_at": "2024-11-01T00:00:00Z", - "description": "A test committee", - "key": "test-committee", - "name": "Test Committee", - "updated_at": "2024-07-02T00:00:00Z", - }, - { - "created_at": "2023-12-01T00:00:00Z", - "description": "A committee without a name", - "key": "this-is-a-committee", - "name": "this is a committee", - "updated_at": "2023-09-02T00:00:00Z", - }, - ], -) -def test_committee_serializer_validation(committee_data): - class MockCommittee: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - self.nest_key = data["key"] - - committee = CommitteeDetail.from_orm(MockCommittee(committee_data)) - - assert committee.created_at == datetime.fromisoformat(committee_data["created_at"]) - assert committee.description == committee_data["description"] - assert committee.key == committee_data["key"] - assert committee.name == committee_data["name"] - assert committee.updated_at == datetime.fromisoformat(committee_data["updated_at"]) +class TestCommitteeSerializerValidation: + @pytest.mark.parametrize( + "committee_data", + [ + { + "created_at": "2024-11-01T00:00:00Z", + "description": "A test committee", + "key": "test-committee", + "name": "Test Committee", + "updated_at": "2024-07-02T00:00:00Z", + }, + { + "created_at": "2023-12-01T00:00:00Z", + "description": "A committee without a name", + "key": "this-is-a-committee", + "name": "this is a committee", + "updated_at": "2023-09-02T00:00:00Z", + }, + ], + ) + def test_committee_serializer_validation(self, committee_data): + class MockCommittee: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + + committee = CommitteeDetail.from_orm(MockCommittee(committee_data)) + + assert committee.created_at == datetime.fromisoformat(committee_data["created_at"]) + assert committee.description == committee_data["description"] + assert committee.key == committee_data["key"] + assert committee.name == committee_data["name"] + assert committee.updated_at == datetime.fromisoformat(committee_data["updated_at"]) class TestListCommittees: diff --git a/backend/tests/apps/api/rest/v0/common_test.py b/backend/tests/apps/api/rest/v0/common_test.py index 5eec6b87f1..6b18d43984 100644 --- a/backend/tests/apps/api/rest/v0/common_test.py +++ b/backend/tests/apps/api/rest/v0/common_test.py @@ -3,27 +3,28 @@ from apps.api.rest.v0.common import LocationFilter -@pytest.mark.parametrize( - "location_data", - [ - { - "latitude_gte": -5.0, - "latitude_lte": 5.0, - "longitude_gte": -10.0, - "longitude_lte": 10.0, - }, - { - "latitude_gte": 15.0, - "latitude_lte": 25.0, - "longitude_gte": 20.0, - "longitude_lte": 30.0, - }, - ], -) -def test_location_filter_validation(location_data): - location_filter = LocationFilter(**location_data) +class TestLocationFilter: + @pytest.mark.parametrize( + "location_data", + [ + { + "latitude_gte": -5.0, + "latitude_lte": 5.0, + "longitude_gte": -10.0, + "longitude_lte": 10.0, + }, + { + "latitude_gte": 15.0, + "latitude_lte": 25.0, + "longitude_gte": 20.0, + "longitude_lte": 30.0, + }, + ], + ) + def test_location_filter_validation(self, location_data): + location_filter = LocationFilter(**location_data) - assert location_filter.latitude_gte == location_data["latitude_gte"] - assert location_filter.latitude_lte == location_data["latitude_lte"] - assert location_filter.longitude_gte == location_data["longitude_gte"] - assert location_filter.longitude_lte == location_data["longitude_lte"] + assert location_filter.latitude_gte == location_data["latitude_gte"] + assert location_filter.latitude_lte == location_data["latitude_lte"] + assert location_filter.longitude_gte == location_data["longitude_gte"] + assert location_filter.longitude_lte == location_data["longitude_lte"] diff --git a/backend/tests/apps/api/rest/v0/event_test.py b/backend/tests/apps/api/rest/v0/event_test.py index 0f010b5461..faec74c4da 100644 --- a/backend/tests/apps/api/rest/v0/event_test.py +++ b/backend/tests/apps/api/rest/v0/event_test.py @@ -11,43 +11,44 @@ current_timezone = timezone.get_current_timezone() -@pytest.mark.parametrize( - "event_object", - [ - EventModel( - description="this is a sample event", - end_date=datetime(2023, 6, 15, tzinfo=current_timezone).date(), - key="sample-event", - latitude=59.9139, - longitude=10.7522, - name="sample event", - start_date=datetime(2023, 6, 14, tzinfo=current_timezone).date(), - url="https://github.com/owasp/Nest", - ), - EventModel( - description=None, - end_date=None, - key="event-without-end-date", - latitude=None, - longitude=None, - name="event without end date", - start_date=datetime(2023, 7, 1, tzinfo=current_timezone).date(), - url=None, - ), - ], -) -def test_event_serializer_validation(event_object: EventModel): - event = EventDetail.from_orm(event_object) - - assert event.description == event_object.description - end_date = event_object.end_date.isoformat() if event_object.end_date else None - assert event.end_date == end_date - assert event.key == event_object.key - assert event.latitude == event_object.latitude - assert event.longitude == event_object.longitude - assert event.name == event_object.name - assert event.start_date == event_object.start_date.isoformat() - assert event.url == event_object.url +class TestEventSerializerValidation: + @pytest.mark.parametrize( + "event_object", + [ + EventModel( + description="this is a sample event", + end_date=datetime(2023, 6, 15, tzinfo=current_timezone).date(), + key="sample-event", + latitude=59.9139, + longitude=10.7522, + name="sample event", + start_date=datetime(2023, 6, 14, tzinfo=current_timezone).date(), + url="https://github.com/owasp/Nest", + ), + EventModel( + description=None, + end_date=None, + key="event-without-end-date", + latitude=None, + longitude=None, + name="event without end date", + start_date=datetime(2023, 7, 1, tzinfo=current_timezone).date(), + url=None, + ), + ], + ) + def test_event_serializer_validation(self, event_object: EventModel): + event = EventDetail.from_orm(event_object) + + assert event.description == event_object.description + end_date = event_object.end_date.isoformat() if event_object.end_date else None + assert event.end_date == end_date + assert event.key == event_object.key + assert event.latitude == event_object.latitude + assert event.longitude == event_object.longitude + assert event.name == event_object.name + assert event.start_date == event_object.start_date.isoformat() + assert event.url == event_object.url class TestListEvents: diff --git a/backend/tests/apps/api/rest/v0/project_test.py b/backend/tests/apps/api/rest/v0/project_test.py index c1bf53a479..0eb8d39b61 100644 --- a/backend/tests/apps/api/rest/v0/project_test.py +++ b/backend/tests/apps/api/rest/v0/project_test.py @@ -7,60 +7,61 @@ from apps.api.rest.v0.project import ProjectDetail, get_project, list_projects -@pytest.mark.parametrize( - "project_data", - [ - { - "created_at": "2023-01-01T00:00:00Z", - "description": "A test project by owasp", - "key": "another-project", - "level": "other", - "name": "another project", - "updated_at": "2023-01-02T00:00:00Z", - }, - { - "created_at": "2023-01-01T00:00:00Z", - "description": "this is not a project, this is just a file", - "key": "this-is-a-project", - "level": "incubator", - "name": "this is a project", - "updated_at": "2023-01-02T00:00:00Z", - }, - ], -) -def test_project_serializer_validation(project_data): - class MockMember: - def __init__(self, login): - self.login = login - - class MockEntityMember: - def __init__(self, name, login=None): - self.member = MockMember(login) if login else None - self.member_name = name - - class MockProject: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - self.nest_key = data["key"] - self.entity_leaders = [ - MockEntityMember("Alice", "alice"), - MockEntityMember("Bob"), - ] - - project = ProjectDetail.from_orm(MockProject(project_data)) - - assert project.created_at == datetime.fromisoformat(project_data["created_at"]) - assert project.description == project_data["description"] - assert project.key == project_data["key"] - assert len(project.leaders) == 2 - assert project.leaders[0].key == "alice" - assert project.leaders[0].name == "Alice" - assert project.leaders[1].key is None - assert project.leaders[1].name == "Bob" - assert project.level == project_data["level"] - assert project.name == project_data["name"] - assert project.updated_at == datetime.fromisoformat(project_data["updated_at"]) +class TestProjectSerializerValidation: + @pytest.mark.parametrize( + "project_data", + [ + { + "created_at": "2023-01-01T00:00:00Z", + "description": "A test project by owasp", + "key": "another-project", + "level": "other", + "name": "another project", + "updated_at": "2023-01-02T00:00:00Z", + }, + { + "created_at": "2023-01-01T00:00:00Z", + "description": "this is not a project, this is just a file", + "key": "this-is-a-project", + "level": "incubator", + "name": "this is a project", + "updated_at": "2023-01-02T00:00:00Z", + }, + ], + ) + def test_project_serializer_validation(self, project_data): + class MockMember: + def __init__(self, login): + self.login = login + + class MockEntityMember: + def __init__(self, name, login=None): + self.member = MockMember(login) if login else None + self.member_name = name + + class MockProject: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + self.entity_leaders = [ + MockEntityMember("Alice", "alice"), + MockEntityMember("Bob"), + ] + + project = ProjectDetail.from_orm(MockProject(project_data)) + + assert project.created_at == datetime.fromisoformat(project_data["created_at"]) + assert project.description == project_data["description"] + assert project.key == project_data["key"] + assert len(project.leaders) == 2 + assert project.leaders[0].key == "alice" + assert project.leaders[0].name == "Alice" + assert project.leaders[1].key is None + assert project.leaders[1].name == "Bob" + assert project.level == project_data["level"] + assert project.name == project_data["name"] + assert project.updated_at == datetime.fromisoformat(project_data["updated_at"]) class TestListProjects: diff --git a/backend/tests/apps/api/rest/v0/structured_search_test.py b/backend/tests/apps/api/rest/v0/structured_search_test.py index 326093c317..1e46025fbe 100644 --- a/backend/tests/apps/api/rest/v0/structured_search_test.py +++ b/backend/tests/apps/api/rest/v0/structured_search_test.py @@ -16,178 +16,167 @@ def make_queryset(): return qs -def test_string_search_conversion(): - qs = make_queryset() - apply_structured_search(qs, "name:nest", FIELD_SCHEMA) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - assert "name__icontains" in str(args[0]) - - -def test_numeric_comparison_conversion(): - qs = make_queryset() - apply_structured_search(qs, "stars:>10", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "stars_count__gt" in str(args[0]) - - -def test_field_alias_mapping(): - qs = make_queryset() - apply_structured_search(qs, "stars:>=50", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "stars_count__gte" in str(args[0]) - - -def test_invalid_syntax_returns_original_queryset(): - qs = make_queryset() - apply_structured_search(qs, "stars:!!!", FIELD_SCHEMA) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - - assert "stars_count" not in str(args[0]) - - -def test_unknown_field_is_ignored(): - qs = make_queryset() - apply_structured_search(qs, "invalid_field:value", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "invalid_field" not in str(args[0]) - - -def test_empty_query_returns_original_queryset(): - """Test that empty query returns original queryset.""" - qs = make_queryset() - result = apply_structured_search(qs, "", FIELD_SCHEMA) - - assert result == qs - qs.filter.assert_not_called() - - -def test_none_query_returns_original_queryset(): - """Test that None query returns original queryset.""" - qs = make_queryset() - result = apply_structured_search(qs, None, FIELD_SCHEMA) - - assert result == qs - qs.filter.assert_not_called() - - -def test_invalid_number_value_is_skipped(): - """Test that invalid number value is skipped.""" - qs = make_queryset() - apply_structured_search(qs, "stars:not_a_number", FIELD_SCHEMA) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - assert "stars_count" not in str(args[0]) - - -def test_less_than_operator(): - """Test less than operator for numeric fields.""" - qs = make_queryset() - apply_structured_search(qs, "stars:<5", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "stars_count__lt" in str(args[0]) - - -def test_string_field_with_exact_lookup(): - """Test string field with exact lookup.""" - schema_with_exact = { - "name": {"type": "string", "lookup": "exact"}, - } - qs = make_queryset() - apply_structured_search(qs, "name:test", schema_with_exact) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - assert "name__icontains" not in str(args[0]) - - -def test_less_than_equal_operator(): - """Test less than or equal operator for numeric fields.""" - qs = make_queryset() - apply_structured_search(qs, "stars:<=100", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "stars_count__lte" in str(args[0]) - - -def test_equal_operator_numeric(): - """Test equals operator for numeric fields.""" - qs = make_queryset() - apply_structured_search(qs, "stars:=42", FIELD_SCHEMA) - - args, _ = qs.filter.call_args - assert "stars_count" in str(args[0]) - - -def test_multiple_conditions(): - """Test query with multiple field conditions.""" - qs = make_queryset() - apply_structured_search(qs, "name:nest stars:>10", FIELD_SCHEMA) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - filter_str = str(args[0]) - assert "name__icontains" in filter_str - assert "stars_count__gt" in filter_str - - -def test_query_parser_error_returns_original_queryset(): - """Test that QueryParserError returns original queryset.""" - qs = make_queryset() - with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: - mock_parser_class.side_effect = QueryParserError("Test error") - result = apply_structured_search(qs, "name:test", FIELD_SCHEMA) - - assert result == qs - qs.filter.assert_not_called() - - -def test_condition_field_not_in_schema_is_skipped(): - """Test that condition with field not in field_schema is skipped.""" - qs = make_queryset() - with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: - mock_parser = MagicMock() - mock_parser_class.return_value = mock_parser - mock_parser.parse.return_value = [{"field": "query", "type": "string", "value": "test"}] - apply_structured_search(qs, "test", FIELD_SCHEMA) - - qs.filter.assert_called_once() - - -def test_boolean_field_uses_no_lookup_suffix(): - """Test boolean field uses empty lookup suffix.""" - schema_with_boolean = { - "active": {"type": "boolean", "field": "is_active"}, - } - qs = make_queryset() - with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: - mock_parser = MagicMock() - mock_parser_class.return_value = mock_parser - mock_parser.parse.return_value = [{"field": "active", "type": "boolean", "value": True}] - apply_structured_search(qs, "active:true", schema_with_boolean) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - assert "is_active" in str(args[0]) - - -def test_number_field_with_none_value_is_skipped(): - """Test that number field with None value is skipped.""" - qs = make_queryset() - with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: - mock_parser = MagicMock() - mock_parser_class.return_value = mock_parser - mock_parser.parse.return_value = [{"field": "stars", "type": "number", "value": None}] - apply_structured_search(qs, "stars:", FIELD_SCHEMA) - - qs.filter.assert_called_once() - args, _ = qs.filter.call_args - assert "stars" not in str(args[0]) +class TestApplyStructuredSearch: + def test_string_search_conversion(self): + qs = make_queryset() + apply_structured_search(qs, "name:nest", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "name__icontains" in str(args[0]) + + def test_numeric_comparison_conversion(self): + qs = make_queryset() + apply_structured_search(qs, "stars:>10", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__gt" in str(args[0]) + + def test_field_alias_mapping(self): + qs = make_queryset() + apply_structured_search(qs, "stars:>=50", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__gte" in str(args[0]) + + def test_invalid_syntax_skips_condition_and_applies_empty_filter(self): + qs = make_queryset() + result = apply_structured_search(qs, "stars:!!!", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "stars_count" not in str(args[0]) + assert result == qs.filter.return_value + + def test_unknown_field_is_ignored(self): + qs = make_queryset() + apply_structured_search(qs, "invalid_field:value", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "invalid_field" not in str(args[0]) + + def test_empty_query_returns_original_queryset(self): + """Test that empty query returns original queryset.""" + qs = make_queryset() + result = apply_structured_search(qs, "", FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + def test_none_query_returns_original_queryset(self): + """Test that None query returns original queryset.""" + qs = make_queryset() + result = apply_structured_search(qs, None, FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + def test_invalid_number_value_is_skipped(self): + """Test that invalid number value is skipped.""" + qs = make_queryset() + apply_structured_search(qs, "stars:not_a_number", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "stars_count" not in str(args[0]) + + def test_less_than_operator(self): + """Test less than operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:<5", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__lt" in str(args[0]) + + def test_string_field_with_exact_lookup(self): + """Test string field with exact lookup.""" + schema_with_exact = { + "name": {"type": "string", "lookup": "exact"}, + } + qs = make_queryset() + apply_structured_search(qs, "name:test", schema_with_exact) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "name__icontains" not in str(args[0]) + + def test_less_than_equal_operator(self): + """Test less than or equal operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:<=100", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count__lte" in str(args[0]) + + def test_equal_operator_numeric(self): + """Test equals operator for numeric fields.""" + qs = make_queryset() + apply_structured_search(qs, "stars:=42", FIELD_SCHEMA) + + args, _ = qs.filter.call_args + assert "stars_count" in str(args[0]) + + def test_multiple_conditions(self): + """Test query with multiple field conditions.""" + qs = make_queryset() + apply_structured_search(qs, "name:nest stars:>10", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + filter_str = str(args[0]) + assert "name__icontains" in filter_str + assert "stars_count__gt" in filter_str + + def test_query_parser_error_returns_original_queryset(self): + """Test that QueryParserError returns original queryset.""" + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser_class.side_effect = QueryParserError("Test error") + result = apply_structured_search(qs, "name:test", FIELD_SCHEMA) + + assert result == qs + qs.filter.assert_not_called() + + def test_condition_field_not_in_schema_is_skipped(self): + """Test that condition with field not in field_schema is skipped.""" + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [ + {"field": "query", "type": "string", "value": "test"} + ] + apply_structured_search(qs, "test", FIELD_SCHEMA) + + qs.filter.assert_called_once() + + def test_boolean_field_uses_no_lookup_suffix(self): + """Test boolean field uses empty lookup suffix.""" + schema_with_boolean = { + "active": {"type": "boolean", "field": "is_active"}, + } + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [ + {"field": "active", "type": "boolean", "value": True} + ] + apply_structured_search(qs, "active:true", schema_with_boolean) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "is_active" in str(args[0]) + + def test_number_field_with_none_value_is_skipped(self): + """Test that number field with None value is skipped.""" + qs = make_queryset() + with patch("apps.api.rest.v0.structured_search.QueryParser") as mock_parser_class: + mock_parser = MagicMock() + mock_parser_class.return_value = mock_parser + mock_parser.parse.return_value = [{"field": "stars", "type": "number", "value": None}] + apply_structured_search(qs, "stars:", FIELD_SCHEMA) + + qs.filter.assert_called_once() + args, _ = qs.filter.call_args + assert "stars" not in str(args[0]) diff --git a/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py b/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py index 2b3f00f0f5..046cf7589f 100644 --- a/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py +++ b/backend/tests/apps/common/management/commands/add_project_custom_tags_test.py @@ -1,4 +1,5 @@ import json +from argparse import ArgumentParser from io import StringIO from pathlib import Path from unittest.mock import MagicMock, patch @@ -9,6 +10,14 @@ class TestAddProjectCustomTags: + def test_add_arguments(self): + """Test add_arguments adds expected arguments.""" + command = Command() + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args(["test-file.json"]) + assert getattr(args, "file-name") == "test-file.json" + @pytest.mark.parametrize( ("file_exists", "file_content", "expected_output"), [ diff --git a/backend/tests/apps/github/management/commands/github_add_related_repositories_test.py b/backend/tests/apps/github/management/commands/github_add_related_repositories_test.py index f7d0aa56b1..9d8a14e777 100644 --- a/backend/tests/apps/github/management/commands/github_add_related_repositories_test.py +++ b/backend/tests/apps/github/management/commands/github_add_related_repositories_test.py @@ -9,301 +9,326 @@ ) -@pytest.fixture -def command(): - return Command() - - -@pytest.fixture -def mock_project(): - project = mock.Mock(spec=Project) - project.owasp_url = "https://owasp.org/www-project-test" - project.related_urls = mock.MagicMock() - project.related_urls.copy.return_value = ["https://github.com/OWASP/test-repo"] - project.invalid_urls = mock.MagicMock() - project.repositories = mock.Mock() - project.repositories.add = mock.MagicMock() - project.save = mock.MagicMock() - return project - - -@pytest.mark.parametrize( - ("offset", "projects"), - [ - (0, 3), - (2, 5), - (0, 6), - (1, 8), - ], -) -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -def test_handle( - mock_get_repository_path, - mock_sync_repository, - mock_get_github_client, - command, - mock_project, - offset, - projects, -): - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - - mock_get_repository_path.return_value = "OWASP/test-repo" - - mock_gh_repo = mock.Mock(name="test-repo") - mock_gh_client.get_repo.return_value = mock_gh_repo - - mock_organization = mock.Mock() - mock_repository = mock.Mock() - mock_sync_repository.return_value = (mock_organization, mock_repository) - - mock_projects_list = [mock_project] * projects - - mock_active_projects = mock.MagicMock() - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) - mock_active_projects.__getitem__ = lambda _, idx: ( - mock_projects_list[idx] - if isinstance(idx, int) - else mock_projects_list[idx.start : idx.stop] +class TestGithubAddRelatedRepositories: + @pytest.fixture + def command(self): + return Command() + + @pytest.fixture + def mock_project(self): + project = mock.Mock(spec=Project) + project.owasp_url = "https://owasp.org/www-project-test" + project.related_urls = mock.MagicMock() + project.related_urls.copy.return_value = ["https://github.com/OWASP/test-repo"] + project.invalid_urls = mock.MagicMock() + project.repositories = mock.Mock() + project.repositories.add = mock.MagicMock() + project.save = mock.MagicMock() + return project + + @pytest.mark.parametrize( + ("offset", "projects"), + [ + (0, 3), + (2, 5), + (0, 6), + (1, 8), + ], ) - mock_active_projects.order_by.return_value = mock_active_projects - - with ( - mock.patch.object(Project, "active_projects", mock_active_projects), - mock.patch.object(Project, "bulk_save") as mock_project_bulk_save, + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" + ) + def test_handle( + self, + mock_get_repository_path, + mock_sync_repository, + mock_get_github_client, + command, + mock_project, + offset, + projects, ): - command.stdout = mock.MagicMock() - command.handle(offset=offset) + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client - mock_get_github_client.assert_called_once() + mock_get_repository_path.return_value = "OWASP/test-repo" - mock_get_repository_path.assert_called_with("https://github.com/OWASP/test-repo") - mock_gh_client.get_repo.assert_called_with("OWASP/test-repo") + mock_gh_repo = mock.Mock(name="test-repo") + mock_gh_client.get_repo.return_value = mock_gh_repo - assert mock_organization.save.called - assert mock_project.repositories.add.called + mock_organization = mock.Mock() + mock_repository = mock.Mock() + mock_sync_repository.return_value = (mock_organization, mock_repository) - assert mock_sync_repository.call_count == projects - offset + mock_projects_list = [mock_project] * projects - mock_project_bulk_save.assert_called_once() + mock_active_projects = mock.MagicMock() + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) + mock_active_projects.__getitem__ = lambda _, idx: ( + mock_projects_list[idx] + if isinstance(idx, int) + else mock_projects_list[idx.start : idx.stop] + ) + mock_active_projects.order_by.return_value = mock_active_projects - assert command.stdout.write.call_count > 0 + with ( + mock.patch.object(Project, "active_projects", mock_active_projects), + mock.patch.object(Project, "bulk_save") as mock_project_bulk_save, + ): + command.stdout = mock.MagicMock() + command.handle(offset=offset) + mock_get_github_client.assert_called_once() -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -@mock.patch("apps.owasp.models.project.Project.active_projects") -def test_handle_unknown_object_exception( - mock_active_projects, - mock_get_repository_path, - mock_sync_repository, - mock_get_github_client, - command, - mock_project, -): - """Test handling of a 404 Not Found error from GitHub.""" - mock_projects_list = [mock_project] - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) + mock_get_repository_path.assert_called_with("https://github.com/OWASP/test-repo") + mock_gh_client.get_repo.assert_called_with("OWASP/test-repo") - mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] - mock_active_projects.order_by.return_value = mock_active_projects + assert mock_organization.save.called + assert mock_project.repositories.add.called - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client + assert mock_sync_repository.call_count == projects - offset - def raise_404(*a, **k): # noqa: ARG001 - raise UnknownObjectException( - status=404, - data={"message": "Not Found", "status": "404"}, - headers={}, - ) + mock_project_bulk_save.assert_called_once() + + assert command.stdout.write.call_count > 0 + + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" + ) + @mock.patch("apps.owasp.models.project.Project.active_projects") + def test_handle_unknown_object_exception( + self, + mock_active_projects, + mock_get_repository_path, + mock_sync_repository, + mock_get_github_client, + command, + mock_project, + ): + """Test handling of a 404 Not Found error from GitHub.""" + mock_projects_list = [mock_project] + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) - mock_gh_client.get_repo.side_effect = raise_404 + mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] + mock_active_projects.order_by.return_value = mock_active_projects - mock_get_repository_path.return_value = "OWASP/test-repo" - with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: - command.handle(offset=0) + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client - mock_project.related_urls.remove.assert_called_once_with("https://github.com/OWASP/test-repo") - mock_project.invalid_urls.add.assert_called_once_with("https://github.com/OWASP/test-repo") - mock_project.save.assert_called_once_with(update_fields=("invalid_urls", "related_urls")) + def raise_404(*a, **k): # noqa: ARG001 + raise UnknownObjectException( + status=404, + data={"message": "Not Found", "status": "404"}, + headers={}, + ) - mock_sync_repository.assert_not_called() + mock_gh_client.get_repo.side_effect = raise_404 - mock_project_bulk_save.assert_called_once_with([mock_project]) + mock_get_repository_path.return_value = "OWASP/test-repo" + with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: + command.handle(offset=0) + mock_project.related_urls.remove.assert_called_once_with( + "https://github.com/OWASP/test-repo" + ) + mock_project.invalid_urls.add.assert_called_once_with("https://github.com/OWASP/test-repo") + mock_project.save.assert_called_once_with(update_fields=("invalid_urls", "related_urls")) -@mock.patch("apps.github.management.commands.github_add_related_repositories.logger") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -@mock.patch("apps.owasp.models.project.Project.active_projects") -def test_handle_sync_repository_exception( - mock_active_projects, - mock_get_repository_path, - mock_sync_repository, - mock_get_github_client, - mock_logger, - command, - mock_project, -): - """Test that an exception during sync_repository is logged and handled.""" - mock_projects_list = [mock_project] - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) + mock_sync_repository.assert_not_called() - mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] - mock_active_projects.order_by.return_value = mock_active_projects + mock_project_bulk_save.assert_called_once_with([mock_project]) - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client + @mock.patch("apps.github.management.commands.github_add_related_repositories.logger") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" + ) + @mock.patch("apps.owasp.models.project.Project.active_projects") + def test_handle_sync_repository_exception( + self, + mock_active_projects, + mock_get_repository_path, + mock_sync_repository, + mock_get_github_client, + mock_logger, + command, + mock_project, + ): + """Test that an exception during sync_repository is logged and handled.""" + mock_projects_list = [mock_project] + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) - mock_gh_repository = mock.MagicMock() - mock_gh_client.get_repo.return_value = mock_gh_repository + mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] + mock_active_projects.order_by.return_value = mock_active_projects - mock_sync_repository.side_effect = Exception("Test sync error") - mock_get_repository_path.return_value = "OWASP/test-repo" + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client - with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: - command.handle(offset=0) + mock_gh_repository = mock.MagicMock() + mock_gh_client.get_repo.return_value = mock_gh_repository - mock_logger.exception.assert_called_once_with( - "Error syncing repository %s", "https://github.com/OWASP/test-repo" - ) - mock_project_bulk_save.assert_called_once_with([mock_project]) - - -@mock.patch("apps.github.management.commands.github_add_related_repositories.logger") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.owasp.models.project.Project.active_projects") -def test_handle_no_repository_path( - mock_active_projects, - mock_get_github_client, - mock_sync_repository, - mock_get_repository_path, - mock_logger, - command, - mock_project, -): - """Test that the command handles the case where a repository path cannot be determined.""" - mock_get_repository_path.return_value = None - mock_projects_list = [mock_project] - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) - - mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] - mock_active_projects.order_by.return_value = mock_active_projects - - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - - mock_gh_repository = mock.MagicMock() - mock_gh_client.get_repo.return_value = mock_gh_repository - - with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: - command.handle(offset=0) - - mock_get_repository_path.assert_called_once_with("https://github.com/OWASP/test-repo") - mock_logger.info.assert_called_once_with( - "Couldn't get repository path for %s", "https://github.com/OWASP/test-repo" + mock_sync_repository.side_effect = Exception("Test sync error") + mock_get_repository_path.return_value = "OWASP/test-repo" + + with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: + command.handle(offset=0) + + mock_logger.exception.assert_called_once_with( + "Error syncing repository %s", "https://github.com/OWASP/test-repo" + ) + mock_project_bulk_save.assert_called_once_with([mock_project]) + + @mock.patch("apps.github.management.commands.github_add_related_repositories.logger") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" ) - mock_get_github_client.return_value.get_repo.assert_not_called() - mock_sync_repository.assert_not_called() - mock_project_bulk_save.assert_called_once_with([mock_project]) - - -def test_add_arguments(command): - parser = mock.Mock() - command.add_arguments(parser) - parser.add_argument.assert_called_once_with( - "--offset", - type=int, - default=0, - required=False, + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" ) + @mock.patch("apps.owasp.models.project.Project.active_projects") + def test_handle_no_repository_path( + self, + mock_active_projects, + mock_get_github_client, + mock_sync_repository, + mock_get_repository_path, + mock_logger, + command, + mock_project, + ): + """Test that the command handles the case where a repository path cannot be determined.""" + mock_get_repository_path.return_value = None + mock_projects_list = [mock_project] + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) + mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] + mock_active_projects.order_by.return_value = mock_active_projects -@mock.patch("apps.github.management.commands.github_add_related_repositories.logger") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -@mock.patch("apps.owasp.models.project.Project.active_projects") -def test_handle_unknown_object_exception_non_404( - mock_active_projects, - mock_get_repository_path, - mock_sync_repository, - mock_get_github_client, - mock_logger, - command, - mock_project, -): - """Test handling of a non-404 UnknownObjectException falls through.""" - mock_projects_list = [mock_project] - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) - mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] - mock_active_projects.order_by.return_value = mock_active_projects - - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - - def raise_403(*a, **k): # noqa: ARG001 - raise UnknownObjectException( - status=403, - data={"message": "Forbidden", "status": "403"}, - headers={}, - ) + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client + + mock_gh_repository = mock.MagicMock() + mock_gh_client.get_repo.return_value = mock_gh_repository + + with mock.patch.object(Project, "bulk_save") as mock_project_bulk_save: + command.handle(offset=0) - mock_gh_client.get_repo.side_effect = raise_403 - mock_get_repository_path.return_value = "OWASP/test-repo" + mock_get_repository_path.assert_called_once_with("https://github.com/OWASP/test-repo") + mock_logger.info.assert_called_once_with( + "Couldn't get repository path for %s", "https://github.com/OWASP/test-repo" + ) + mock_get_github_client.return_value.get_repo.assert_not_called() + mock_sync_repository.assert_not_called() + mock_project_bulk_save.assert_called_once_with([mock_project]) + + def test_add_arguments(self, command): + parser = mock.Mock() + command.add_arguments(parser) + parser.add_argument.assert_called_once_with( + "--offset", + type=int, + default=0, + required=False, + ) - with mock.patch.object(Project, "bulk_save"): - command.handle(offset=0) + @mock.patch("apps.github.management.commands.github_add_related_repositories.logger") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" + ) + @mock.patch("apps.owasp.models.project.Project.active_projects") + def test_handle_unknown_object_exception_non_404( + self, + mock_active_projects, + mock_get_repository_path, + mock_sync_repository, + mock_get_github_client, + mock_logger, + command, + mock_project, + ): + """Test handling of a non-404 UnknownObjectException falls through.""" + mock_projects_list = [mock_project] + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) + mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] + mock_active_projects.order_by.return_value = mock_active_projects + + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client + + def raise_403(*a, **k): # noqa: ARG001 + raise UnknownObjectException( + status=403, + data={"message": "Forbidden", "status": "403"}, + headers={}, + ) + + mock_gh_client.get_repo.side_effect = raise_403 + mock_get_repository_path.return_value = "OWASP/test-repo" + + with mock.patch.object(Project, "bulk_save"): + command.handle(offset=0) + + mock_logger.exception.assert_called_once_with( + "Unexpected error fetching repository %s", "https://github.com/OWASP/test-repo" + ) + mock_sync_repository.assert_not_called() + mock_project.invalid_urls.add.assert_not_called() - mock_logger.exception.assert_called_once_with( - "Unexpected error fetching repository %s", "https://github.com/OWASP/test-repo" + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_github_client" ) - mock_sync_repository.assert_not_called() - mock_project.invalid_urls.add.assert_not_called() - - -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_github_client") -@mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") -@mock.patch("apps.github.management.commands.github_add_related_repositories.get_repository_path") -@mock.patch("apps.owasp.models.project.Project.active_projects") -def test_handle_sync_repository_returns_none_organization( - mock_active_projects, - mock_get_repository_path, - mock_sync_repository, - mock_get_github_client, - command, - mock_project, -): - """Test sync_repository returning None organization.""" - mock_projects_list = [mock_project] - mock_active_projects.__iter__.return_value = iter(mock_projects_list) - mock_active_projects.count.return_value = len(mock_projects_list) - mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] - mock_active_projects.order_by.return_value = mock_active_projects - - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - mock_gh_repo = mock.Mock() - mock_gh_client.get_repo.return_value = mock_gh_repo - - mock_repository = mock.Mock() - mock_sync_repository.return_value = (None, mock_repository) - mock_get_repository_path.return_value = "OWASP/test-repo" - - with mock.patch.object(Project, "bulk_save"): - command.handle(offset=0) - - mock_project.repositories.add.assert_called_once_with(mock_repository) + @mock.patch("apps.github.management.commands.github_add_related_repositories.sync_repository") + @mock.patch( + "apps.github.management.commands.github_add_related_repositories.get_repository_path" + ) + @mock.patch("apps.owasp.models.project.Project.active_projects") + def test_handle_sync_repository_returns_none_organization( + self, + mock_active_projects, + mock_get_repository_path, + mock_sync_repository, + mock_get_github_client, + command, + mock_project, + ): + """Test sync_repository returning None organization.""" + mock_projects_list = [mock_project] + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = len(mock_projects_list) + mock_active_projects.__getitem__.side_effect = lambda idx: mock_projects_list[idx] + mock_active_projects.order_by.return_value = mock_active_projects + + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client + mock_gh_repo = mock.Mock() + mock_gh_client.get_repo.return_value = mock_gh_repo + + mock_repository = mock.Mock() + mock_sync_repository.return_value = (None, mock_repository) + mock_get_repository_path.return_value = "OWASP/test-repo" + + with mock.patch.object(Project, "bulk_save"): + command.handle(offset=0) + + mock_project.repositories.add.assert_called_once_with(mock_repository) diff --git a/backend/tests/apps/github/management/commands/github_enrich_issues_test.py b/backend/tests/apps/github/management/commands/github_enrich_issues_test.py index 45fa48efad..e7559f8bd1 100644 --- a/backend/tests/apps/github/management/commands/github_enrich_issues_test.py +++ b/backend/tests/apps/github/management/commands/github_enrich_issues_test.py @@ -5,234 +5,246 @@ from apps.github.management.commands.github_enrich_issues import Command -@pytest.mark.parametrize( - ("argument_name", "expected_properties"), - [ - ("--offset", {"default": 0, "required": False, "type": int}), - ("--force-update-hint", {"default": False, "required": False, "action": "store_true"}), - ("--force-update-summary", {"default": False, "required": False, "action": "store_true"}), - ("--update-hint", {"default": True, "required": False, "action": "store_true"}), - ("--update-summary", {"default": True, "required": False, "action": "store_true"}), - ], -) -def test_add_arguments(argument_name, expected_properties): - mock_parser = MagicMock() - command = Command() - command.add_arguments(mock_parser) - mock_parser.add_argument.assert_any_call(argument_name, **expected_properties) - - -@pytest.mark.parametrize( - ("options", "expected_update_fields", "is_force_update"), - [ - ( - { - "force_update_hint": False, - "force_update_summary": False, - "update_hint": True, - "update_summary": True, - "offset": 0, - }, - ["hint", "summary"], - False, - ), - ( - { - "force_update_hint": True, - "force_update_summary": False, - "update_hint": True, - "update_summary": False, - "offset": 0, - }, - ["hint"], - True, - ), - ( - { - "force_update_hint": False, - "force_update_summary": True, - "update_hint": False, - "update_summary": True, - "offset": 0, - }, - ["summary"], - True, - ), - ], -) -@patch("apps.github.management.commands.github_enrich_issues.OpenAi") -@patch("apps.github.management.commands.github_enrich_issues.Issue") -def test_handle( - mock_issue_class, mock_open_ai_class, options, expected_update_fields, is_force_update -): - mock_open_ai = MagicMock() - mock_open_ai_class.return_value = mock_open_ai - - mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] - for issue in mock_issues: - issue.generate_hint = MagicMock() - issue.generate_summary = MagicMock() - - mock_open_issues = MagicMock() - - mock_ordered_queryset = MagicMock() - mock_ordered_queryset.__iter__.return_value = iter(mock_issues) - mock_ordered_queryset.count.return_value = len(mock_issues) - mock_ordered_queryset.__getitem__ = lambda _, idx: ( - mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] +class TestGithubEnrichIssues: + @pytest.mark.parametrize( + ("argument_name", "expected_properties"), + [ + ("--offset", {"default": 0, "required": False, "type": int}), + ( + "--force-update-hint", + {"default": False, "required": False, "action": "store_true"}, + ), + ( + "--force-update-summary", + {"default": False, "required": False, "action": "store_true"}, + ), + ("--update-hint", {"default": True, "required": False, "action": "store_true"}), + ("--update-summary", {"default": True, "required": False, "action": "store_true"}), + ], ) + def test_add_arguments(self, argument_name, expected_properties): + mock_parser = MagicMock() + command = Command() + command.add_arguments(mock_parser) + mock_parser.add_argument.assert_any_call(argument_name, **expected_properties) + + @pytest.mark.parametrize( + ("options", "expected_update_fields", "is_force_update"), + [ + ( + { + "force_update_hint": False, + "force_update_summary": False, + "update_hint": True, + "update_summary": True, + "offset": 0, + }, + ["hint", "summary"], + False, + ), + ( + { + "force_update_hint": True, + "force_update_summary": False, + "update_hint": True, + "update_summary": False, + "offset": 0, + }, + ["hint"], + True, + ), + ( + { + "force_update_hint": False, + "force_update_summary": True, + "update_hint": False, + "update_summary": True, + "offset": 0, + }, + ["summary"], + True, + ), + ], + ) + @patch("apps.github.management.commands.github_enrich_issues.OpenAi") + @patch("apps.github.management.commands.github_enrich_issues.Issue") + def test_handle( + self, + mock_issue_class, + mock_open_ai_class, + options, + expected_update_fields, + is_force_update, + ): + mock_open_ai = MagicMock() + mock_open_ai_class.return_value = mock_open_ai + + mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] + for issue in mock_issues: + issue.generate_hint = MagicMock() + issue.generate_summary = MagicMock() + + mock_open_issues = MagicMock() + + mock_ordered_queryset = MagicMock() + mock_ordered_queryset.__iter__.return_value = iter(mock_issues) + mock_ordered_queryset.count.return_value = len(mock_issues) + mock_ordered_queryset.__getitem__ = lambda _, idx: ( + mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] + ) + + mock_issue_class.open_issues = mock_open_issues + if is_force_update: + mock_open_issues.order_by.return_value = mock_ordered_queryset + else: + mock_open_issues.without_summary = mock_open_issues + mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - mock_issue_class.open_issues = mock_open_issues - if is_force_update: - mock_open_issues.order_by.return_value = mock_ordered_queryset - else: - mock_open_issues.without_summary = mock_open_issues - mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - - command = Command() - command.handle(**options) - - mock_open_ai_class.assert_called_once() + command = Command() + command.handle(**options) - if is_force_update: - mock_open_issues.order_by.assert_called_once_with("-created_at") - else: - mock_open_issues.without_summary.order_by.assert_called_once_with("-created_at") + mock_open_ai_class.assert_called_once() - for issue in mock_issues: - if "hint" in expected_update_fields: - issue.generate_hint.assert_called_once_with(open_ai=mock_open_ai) + if is_force_update: + mock_open_issues.order_by.assert_called_once_with("-created_at") else: - issue.generate_hint.assert_not_called() + mock_open_issues.without_summary.order_by.assert_called_once_with("-created_at") + + for issue in mock_issues: + if "hint" in expected_update_fields: + issue.generate_hint.assert_called_once_with(open_ai=mock_open_ai) + else: + issue.generate_hint.assert_not_called() + + if "summary" in expected_update_fields: + issue.generate_summary.assert_called_once_with(open_ai=mock_open_ai) + else: + issue.generate_summary.assert_not_called() + + mock_issue_class.bulk_save.assert_called_once_with( + mock_issues, fields=expected_update_fields + ) + + @patch("apps.github.management.commands.github_enrich_issues.OpenAi") + @patch("apps.github.management.commands.github_enrich_issues.Issue") + def test_handle_with_offset(self, mock_issue_class, mock_open_ai_class): + """Test --offset argument handling ensuring command skips specified issues.""" + mock_open_ai = MagicMock() + mock_open_ai_class.return_value = mock_open_ai + + mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] + for issue in mock_issues: + issue.generate_hint = MagicMock() + issue.generate_summary = MagicMock() + + mock_open_issues = MagicMock() + + mock_ordered_queryset = MagicMock() + mock_ordered_queryset.__iter__.return_value = iter(mock_issues) + mock_ordered_queryset.count.return_value = len(mock_issues) + mock_ordered_queryset.__getitem__ = lambda _, idx: ( + mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] + ) + + mock_issue_class.open_issues = mock_open_issues + mock_open_issues.without_summary = mock_open_issues + mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - if "summary" in expected_update_fields: - issue.generate_summary.assert_called_once_with(open_ai=mock_open_ai) - else: + command = Command() + options = { + "force_update_hint": False, + "force_update_summary": False, + "update_hint": True, + "update_summary": True, + "offset": 2, + } + command.handle(**options) + + for issue in mock_issues[:2]: + issue.generate_hint.assert_not_called() issue.generate_summary.assert_not_called() - mock_issue_class.bulk_save.assert_called_once_with(mock_issues, fields=expected_update_fields) - - -@patch("apps.github.management.commands.github_enrich_issues.OpenAi") -@patch("apps.github.management.commands.github_enrich_issues.Issue") -def test_handle_with_offset(mock_issue_class, mock_open_ai_class): - """Test --offset argument handling ensuring command skips specified issues.""" - mock_open_ai = MagicMock() - mock_open_ai_class.return_value = mock_open_ai - - mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] - for issue in mock_issues: - issue.generate_hint = MagicMock() - issue.generate_summary = MagicMock() + for issue in mock_issues[2:]: + issue.generate_hint.assert_called_once_with(open_ai=mock_open_ai) + issue.generate_summary.assert_called_once_with(open_ai=mock_open_ai) - mock_open_issues = MagicMock() + mock_issue_class.bulk_save.assert_called_once_with( + mock_issues[2:], fields=["hint", "summary"] + ) + + @patch("apps.github.management.commands.github_enrich_issues.OpenAi") + @patch("apps.github.management.commands.github_enrich_issues.Issue") + def test_handle_with_chunked_save(self, mock_issue_class, mock_open_ai_class): + """Tests that the command correctly processes multiple issues.""" + mock_open_ai = MagicMock() + mock_open_ai_class.return_value = mock_open_ai + mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(50)] + for issue in mock_issues: + issue.generate_hint = MagicMock() + issue.generate_summary = MagicMock() + + mock_open_issues = MagicMock() + mock_issue_class.open_issues = mock_open_issues + mock_open_issues.without_summary = mock_open_issues - mock_ordered_queryset = MagicMock() - mock_ordered_queryset.__iter__.return_value = iter(mock_issues) - mock_ordered_queryset.count.return_value = len(mock_issues) - mock_ordered_queryset.__getitem__ = lambda _, idx: ( - mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] - ) + mock_queryset = MagicMock() + mock_queryset.count.return_value = len(mock_issues) + mock_queryset.__getitem__.side_effect = mock_issues.__getitem__ + mock_open_issues.without_summary.order_by.return_value = mock_queryset + + command = Command() + options = { + "force_update_hint": False, + "force_update_summary": False, + "offset": 0, + "update_hint": True, + "update_summary": True, + } + command.handle(**options) + + assert mock_issue_class.bulk_save.call_count == 1 + + args, kwargs = mock_issue_class.bulk_save.call_args_list[0] + assert len(args[0]) == 50 + assert kwargs["fields"] == ["hint", "summary"] + + @patch("apps.github.management.commands.github_enrich_issues.OpenAi") + @patch("apps.github.management.commands.github_enrich_issues.Issue") + def test_handle_no_update_fields(self, mock_issue_class, mock_open_ai_class): + """Test command handling when no fields are specified for update.""" + mock_open_ai = MagicMock() + mock_open_ai_class.return_value = mock_open_ai + + mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] + for issue in mock_issues: + issue.generate_hint = MagicMock() + issue.generate_summary = MagicMock() + + mock_open_issues = MagicMock() + + mock_ordered_queryset = MagicMock() + mock_ordered_queryset.__iter__.return_value = iter(mock_issues) + mock_ordered_queryset.count.return_value = len(mock_issues) + mock_ordered_queryset.__getitem__ = lambda _, idx: ( + mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] + ) + + mock_issue_class.open_issues = mock_open_issues + mock_open_issues.without_summary = mock_open_issues + mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - mock_issue_class.open_issues = mock_open_issues - mock_open_issues.without_summary = mock_open_issues - mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - - command = Command() - options = { - "force_update_hint": False, - "force_update_summary": False, - "update_hint": True, - "update_summary": True, - "offset": 2, - } - command.handle(**options) - - for issue in mock_issues[:2]: - issue.generate_hint.assert_not_called() - issue.generate_summary.assert_not_called() - - for issue in mock_issues[2:]: - issue.generate_hint.assert_called_once_with(open_ai=mock_open_ai) - issue.generate_summary.assert_called_once_with(open_ai=mock_open_ai) - - mock_issue_class.bulk_save.assert_called_once_with(mock_issues[2:], fields=["hint", "summary"]) - - -@patch("apps.github.management.commands.github_enrich_issues.OpenAi") -@patch("apps.github.management.commands.github_enrich_issues.Issue") -def test_handle_with_chunked_save(mock_issue_class, mock_open_ai_class): - """Tests that the command correctly processes multiple issues.""" - mock_open_ai = MagicMock() - mock_open_ai_class.return_value = mock_open_ai - mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(50)] - for issue in mock_issues: - issue.generate_hint = MagicMock() - issue.generate_summary = MagicMock() - - mock_open_issues = MagicMock() - mock_issue_class.open_issues = mock_open_issues - mock_open_issues.without_summary = mock_open_issues - - mock_queryset = MagicMock() - mock_queryset.count.return_value = len(mock_issues) - mock_queryset.__getitem__.side_effect = mock_issues.__getitem__ - mock_open_issues.without_summary.order_by.return_value = mock_queryset - - command = Command() - options = { - "force_update_hint": False, - "force_update_summary": False, - "offset": 0, - "update_hint": True, - "update_summary": True, - } - command.handle(**options) - - assert mock_issue_class.bulk_save.call_count == 1 - - args, kwargs = mock_issue_class.bulk_save.call_args_list[0] - assert len(args[0]) == 50 - assert kwargs["fields"] == ["hint", "summary"] - - -@patch("apps.github.management.commands.github_enrich_issues.OpenAi") -@patch("apps.github.management.commands.github_enrich_issues.Issue") -def test_handle_no_update_fields(mock_issue_class, mock_open_ai_class): - """Test command handling when no fields are specified for update.""" - mock_open_ai = MagicMock() - mock_open_ai_class.return_value = mock_open_ai - - mock_issues = [MagicMock(title=f"Test Issue {i}") for i in range(5)] - for issue in mock_issues: - issue.generate_hint = MagicMock() - issue.generate_summary = MagicMock() - - mock_open_issues = MagicMock() - - mock_ordered_queryset = MagicMock() - mock_ordered_queryset.__iter__.return_value = iter(mock_issues) - mock_ordered_queryset.count.return_value = len(mock_issues) - mock_ordered_queryset.__getitem__ = lambda _, idx: ( - mock_issues[idx] if isinstance(idx, int) else mock_issues[idx.start : idx.stop] - ) + command = Command() + options = { + "force_update_hint": False, + "force_update_summary": False, + "update_hint": False, + "update_summary": False, + "offset": 0, + } + command.handle(**options) + + for issue in mock_issues: + issue.generate_hint.assert_not_called() + issue.generate_summary.assert_not_called() - mock_issue_class.open_issues = mock_open_issues - mock_open_issues.without_summary = mock_open_issues - mock_open_issues.without_summary.order_by.return_value = mock_ordered_queryset - - command = Command() - options = { - "force_update_hint": False, - "force_update_summary": False, - "update_hint": False, - "update_summary": False, - "offset": 0, - } - command.handle(**options) - - for issue in mock_issues: - issue.generate_hint.assert_not_called() - issue.generate_summary.assert_not_called() - - mock_issue_class.bulk_save.assert_called_once_with(mock_issues, fields=[]) + mock_issue_class.bulk_save.assert_called_once_with(mock_issues, fields=[]) diff --git a/backend/tests/apps/github/management/commands/github_update_owasp_organization_test.py b/backend/tests/apps/github/management/commands/github_update_owasp_organization_test.py index ef3f56b327..9145085c35 100644 --- a/backend/tests/apps/github/management/commands/github_update_owasp_organization_test.py +++ b/backend/tests/apps/github/management/commands/github_update_owasp_organization_test.py @@ -11,99 +11,189 @@ ) -@pytest.mark.parametrize( - ("argument_name", "expected_properties"), - [ - ("--offset", {"default": 0, "required": False, "type": int}), - ( - "--repository", - { - "required": False, - "type": str, - "help": "The OWASP organization's repository name (e.g. Nest, www-project-nest')", - }, - ), - ], -) -def test_add_arguments(argument_name, expected_properties): - mock_parser = mock.Mock() - command = Command() - command.add_arguments(mock_parser) - mock_parser.add_argument.assert_any_call(argument_name, **expected_properties) - - -@pytest.fixture -def command(): - return Command() - - -@pytest.fixture -def mock_gh_repo(): - repo = mock.Mock(spec=Repository) - repo.name = "test-repo" - repo.html_url = "https://github.com/OWASP/test-repo" - return repo - - -@pytest.mark.parametrize( - ("repository_name", "offset", "expected_calls"), - [ - ( - "www-project-test", - 0, - {"project": 1, "chapter": 0, "committee": 0}, - ), - ( - "www-chapter-test", - 0, - {"project": 0, "chapter": 1, "committee": 0}, - ), - ( - "www-committee-test", - 0, - {"project": 0, "chapter": 0, "committee": 1}, - ), - ( - "www-event-test", - 0, - {"project": 0, "chapter": 0, "committee": 0}, - ), - (None, 0, {"project": 1, "chapter": 1, "committee": 1, "event": 1}), - (None, 1, {"project": 0, "chapter": 1, "committee": 1, "event": 1}), - ], -) -@mock.patch("apps.github.management.commands.github_update_owasp_organization.get_github_client") -@mock.patch("apps.github.management.commands.github_update_owasp_organization.sync_repository") -def test_handle( - mock_sync_repository, - mock_get_github_client, - command, - repository_name, - offset, - expected_calls, -): - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - mock_org = mock.Mock() - mock_gh_client.get_organization.return_value = mock_org - - def create_mock_repo(name): - mock_repo = mock.Mock() - mock_repo.name = name - mock_repo.organization.raw_data = {"node_id": "12345"} - return mock_repo - - mock_repos = [ - create_mock_repo("www-project-test"), - create_mock_repo("www-chapter-test"), - create_mock_repo("www-committee-test"), - create_mock_repo("www-other-test"), - ] - - if repository_name: - mock_repo = create_mock_repo(repository_name) - mock_org.get_repo.return_value = mock_repo - else: +class TestGithubUpdateOwaspOrganization: + @pytest.fixture + def command(self): + return Command() + + @pytest.mark.parametrize( + ("argument_name", "expected_properties"), + [ + ("--offset", {"default": 0, "required": False, "type": int}), + ( + "--repository", + { + "required": False, + "type": str, + "help": ( + "The OWASP organization's repository name (e.g. Nest, www-project-nest')" + ), + }, + ), + ], + ) + def test_add_arguments(self, command, argument_name, expected_properties): + mock_parser = mock.Mock() + command.add_arguments(mock_parser) + mock_parser.add_argument.assert_any_call(argument_name, **expected_properties) + + @pytest.mark.parametrize( + ("repository_name", "offset", "expected_calls"), + [ + ( + "www-project-test", + 0, + {"project": 1, "chapter": 0, "committee": 0}, + ), + ( + "www-chapter-test", + 0, + {"project": 0, "chapter": 1, "committee": 0}, + ), + ( + "www-committee-test", + 0, + {"project": 0, "chapter": 0, "committee": 1}, + ), + ( + "www-event-test", + 0, + {"project": 0, "chapter": 0, "committee": 0}, + ), + (None, 0, {"project": 1, "chapter": 1, "committee": 1, "event": 1}), + (None, 1, {"project": 0, "chapter": 1, "committee": 1, "event": 1}), + ], + ) + @mock.patch( + "apps.github.management.commands.github_update_owasp_organization.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_update_owasp_organization.sync_repository") + def test_handle( + self, + mock_sync_repository, + mock_get_github_client, + command, + repository_name, + offset, + expected_calls, + ): + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client + mock_org = mock.Mock() + mock_gh_client.get_organization.return_value = mock_org + + def create_mock_repo(name): + mock_repo = mock.Mock() + mock_repo.name = name + mock_repo.organization.raw_data = {"node_id": "12345"} + return mock_repo + + mock_repos = [ + create_mock_repo("www-project-test"), + create_mock_repo("www-chapter-test"), + create_mock_repo("www-committee-test"), + create_mock_repo("www-other-test"), + ] + + if repository_name: + mock_repo = create_mock_repo(repository_name) + mock_org.get_repo.return_value = mock_repo + else: + + class PaginatedListMock(list): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.totalCount = len(self) + + def __getitem__(self, index): + if isinstance(index, slice): + result = super().__getitem__(index) + new_list = PaginatedListMock(result) + new_list.totalCount = self.totalCount + return new_list + return super().__getitem__(index) + + paginated_repos = PaginatedListMock(mock_repos) + mock_org.get_repos.return_value = paginated_repos + + mock_sync_repository.return_value = (None, Repository()) + mock_repository = Repository() + + with ( + mock.patch.object(Project, "bulk_save") as mock_project_bulk_save, + mock.patch.object(Chapter, "bulk_save") as mock_chapter_bulk_save, + mock.patch.object(Committee, "bulk_save") as mock_committee_bulk_save, + mock.patch.object(Project, "update_data") as mock_project_update, + mock.patch.object(Chapter, "update_data") as mock_chapter_update, + mock.patch.object(Committee, "update_data") as mock_committee_update, + mock.patch.object(Project, "objects") as mock_project_objects, + mock.patch.object(Chapter, "objects") as mock_chapter_objects, + mock.patch.object(Committee, "objects") as mock_committee_objects, + mock.patch.object(Repository, "objects") as mock_repository_objects, + ): + mock_project_update.return_value = mock_repository + mock_chapter_update.return_value = mock_repository + mock_committee_update.return_value = mock_repository + + mock_project_objects.all.return_value = [] + mock_chapter_objects.all.return_value = [] + mock_committee_objects.all.return_value = [] + mock_repository_objects.filter.return_value.count.return_value = 1 + + command.stdout = mock.MagicMock() + command.handle(repository=repository_name, offset=offset) + + mock_get_github_client.assert_called_once() + mock_gh_client.get_organization.assert_called_once_with("OWASP") + + if repository_name: + mock_org.get_repo.assert_called_once_with(repository_name) + else: + mock_org.get_repos.assert_called_once_with( + type="public", sort="created", direction="desc" + ) + + if repository_name: + if repository_name.startswith("www-project-"): + assert mock_project_update.call_count == expected_calls["project"] + elif repository_name.startswith("www-chapter-"): + assert mock_chapter_update.call_count == expected_calls["chapter"] + elif repository_name.startswith("www-committee-"): + assert mock_committee_update.call_count == expected_calls["committee"] + else: + assert mock_project_update.call_count == expected_calls["project"] + assert mock_chapter_update.call_count == expected_calls["chapter"] + assert mock_committee_update.call_count == expected_calls["committee"] + assert command.stdout.write.call_count > 0 + + mock_project_bulk_save.assert_called_once() + mock_chapter_bulk_save.assert_called_once() + mock_committee_bulk_save.assert_called_once() + + @mock.patch( + "apps.github.management.commands.github_update_owasp_organization.get_github_client" + ) + @mock.patch("apps.github.management.commands.github_update_owasp_organization.sync_repository") + @mock.patch("apps.github.management.commands.github_update_owasp_organization.logger") + def test_handle_full_sync_with_errors_and_repo_linking( + self, + mock_logger, + mock_sync_repository, + mock_get_github_client, + command, + ): + """Tests the full organization sync.""" + mock_gh_client = mock.Mock() + mock_get_github_client.return_value = mock_gh_client + mock_org = mock.Mock() + mock_org.public_repos = 3 + mock_gh_client.get_organization.return_value = mock_org + + def create_mock_repo(name): + mock_repo = mock.Mock() + mock_repo.name = name + mock_repo.html_url = f"https://github.com/OWASP/{name}" + return mock_repo class PaginatedListMock(list): def __init__(self, *args, **kwargs): @@ -118,135 +208,45 @@ def __getitem__(self, index): return new_list return super().__getitem__(index) - paginated_repos = PaginatedListMock(mock_repos) - mock_org.get_repos.return_value = paginated_repos - - mock_sync_repository.return_value = (None, Repository()) - mock_repository = Repository() - - with ( - mock.patch.object(Project, "bulk_save") as mock_project_bulk_save, - mock.patch.object(Chapter, "bulk_save") as mock_chapter_bulk_save, - mock.patch.object(Committee, "bulk_save") as mock_committee_bulk_save, - mock.patch.object(Project, "update_data") as mock_project_update, - mock.patch.object(Chapter, "update_data") as mock_chapter_update, - mock.patch.object(Committee, "update_data") as mock_committee_update, - mock.patch.object(Project, "objects") as mock_project_objects, - mock.patch.object(Chapter, "objects") as mock_chapter_objects, - mock.patch.object(Committee, "objects") as mock_committee_objects, - mock.patch.object(Repository, "objects") as mock_repository_objects, - ): - mock_project_update.return_value = mock_repository - mock_chapter_update.return_value = mock_repository - mock_committee_update.return_value = mock_repository - - mock_project_objects.all.return_value = [] - mock_chapter_objects.all.return_value = [] - mock_committee_objects.all.return_value = [] - mock_repository_objects.filter.return_value.count.return_value = 1 - - command.stdout = mock.MagicMock() - command.handle(repository=repository_name, offset=offset) - - mock_get_github_client.assert_called_once() - mock_gh_client.get_organization.assert_called_once_with("OWASP") - - if repository_name: - mock_org.get_repo.assert_called_once_with(repository_name) - else: - mock_org.get_repos.assert_called_once_with( - type="public", sort="created", direction="desc" + repos = [ + create_mock_repo("www-project-test"), + create_mock_repo("www-chapter-error"), + create_mock_repo("www-committee-test"), + ] + mock_repos = PaginatedListMock(repos) + mock_org.get_repos.return_value = mock_repos + mock_repos.totalCount = 3 + mock_sync_repository.side_effect = [ + (mock.Mock(), mock.Mock()), + Exception("Sync failed"), + (mock.Mock(), mock.Mock()), + ] + + with ( + mock.patch.object(Project, "bulk_save"), + mock.patch.object(Chapter, "bulk_save"), + mock.patch.object(Committee, "bulk_save"), + mock.patch.object(Project, "update_data"), + mock.patch.object(Chapter, "update_data"), + mock.patch.object(Committee, "update_data"), + mock.patch.object(Project, "objects") as mock_project_objects, + mock.patch.object(Repository, "objects") as mock_repository_objects, + ): + mock_project = mock.Mock() + mock_project.owasp_repository = mock.Mock() + mock_project_no_repo = mock.Mock() + mock_project_no_repo.owasp_repository = None + mock_project_objects.all.return_value = [mock_project, mock_project_no_repo] + mock_repository_objects.filter.return_value.count.return_value = 2 + command.stdout = mock.MagicMock() + command.handle(repository=None, offset=0) + assert mock_sync_repository.call_count == 3 + mock_logger.exception.assert_called_once_with( + "Error syncing repository %s", "https://github.com/OWASP/www-chapter-error" ) - - if repository_name: - if repository_name.startswith("www-project-"): - assert mock_project_update.call_count == expected_calls["project"] - elif repository_name.startswith("www-chapter-"): - assert mock_chapter_update.call_count == expected_calls["chapter"] - elif repository_name.startswith("www-committee-"): - assert mock_committee_update.call_count == expected_calls["committee"] - else: - assert command.stdout.write.call_count > 0 - - mock_project_bulk_save.assert_called_once() - mock_chapter_bulk_save.assert_called_once() - mock_committee_bulk_save.assert_called_once() - - -@mock.patch("apps.github.management.commands.github_update_owasp_organization.get_github_client") -@mock.patch("apps.github.management.commands.github_update_owasp_organization.sync_repository") -@mock.patch("apps.github.management.commands.github_update_owasp_organization.logger") -def test_handle_full_sync_with_errors_and_repo_linking( - mock_logger, - mock_sync_repository, - mock_get_github_client, - command, -): - """Tests the full organization sync.""" - mock_gh_client = mock.Mock() - mock_get_github_client.return_value = mock_gh_client - mock_org = mock.Mock() - mock_org.public_repos = 3 - mock_gh_client.get_organization.return_value = mock_org - - def create_mock_repo(name): - mock_repo = mock.Mock() - mock_repo.name = name - mock_repo.html_url = f"https://github.com/OWASP/{name}" - return mock_repo - - class PaginatedListMock(list): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.totalCount = len(self) - - def __getitem__(self, index): - if isinstance(index, slice): - result = super().__getitem__(index) - new_list = PaginatedListMock(result) - new_list.totalCount = self.totalCount - return new_list - return super().__getitem__(index) - - repos = [ - create_mock_repo("www-project-test"), - create_mock_repo("www-chapter-error"), - create_mock_repo("www-committee-test"), - ] - mock_repos = PaginatedListMock(repos) - mock_org.get_repos.return_value = mock_repos - mock_repos.totalCount = 3 - mock_sync_repository.side_effect = [ - (mock.Mock(), mock.Mock()), - Exception("Sync failed"), - (mock.Mock(), mock.Mock()), - ] - - with ( - mock.patch.object(Project, "bulk_save"), - mock.patch.object(Chapter, "bulk_save"), - mock.patch.object(Committee, "bulk_save"), - mock.patch.object(Project, "update_data"), - mock.patch.object(Chapter, "update_data"), - mock.patch.object(Committee, "update_data"), - mock.patch.object(Project, "objects") as mock_project_objects, - mock.patch.object(Repository, "objects") as mock_repository_objects, - ): - mock_project = mock.Mock() - mock_project.owasp_repository = mock.Mock() - mock_project_no_repo = mock.Mock() - mock_project_no_repo.owasp_repository = None - mock_project_objects.all.return_value = [mock_project, mock_project_no_repo] - mock_repository_objects.filter.return_value.count.return_value = 2 - command.stdout = mock.MagicMock() - command.handle(repository=None, offset=0) - assert mock_sync_repository.call_count == 3 - mock_logger.exception.assert_called_once_with( - "Error syncing repository %s", "https://github.com/OWASP/www-chapter-error" - ) - command.stdout.write.assert_any_call( - "\nOWASP GitHub repositories count != synced repositories count: 3 != 2" - ) - mock_project_objects.all.assert_called_once() - mock_project.repositories.add.assert_called_once_with(mock_project.owasp_repository) - mock_project_no_repo.repositories.add.assert_not_called() + command.stdout.write.assert_any_call( + "\nOWASP GitHub repositories count != synced repositories count: 3 != 2" + ) + mock_project_objects.all.assert_called_once() + mock_project.repositories.add.assert_called_once_with(mock_project.owasp_repository) + mock_project_no_repo.repositories.add.assert_not_called() diff --git a/backend/tests/apps/github/management/commands/github_update_related_organizations_test.py b/backend/tests/apps/github/management/commands/github_update_related_organizations_test.py index 69071fa3c5..550a3fac85 100644 --- a/backend/tests/apps/github/management/commands/github_update_related_organizations_test.py +++ b/backend/tests/apps/github/management/commands/github_update_related_organizations_test.py @@ -9,39 +9,6 @@ ) -@pytest.fixture -def command(): - return Command() - - -def test_add_arguments(command): - """Test that the command's arguments are correctly added.""" - parser = mock.Mock() - command.add_arguments(parser) - parser.add_argument.assert_called_once_with( - "--organization", - required=False, - type=str, - help="The organization name (e.g. juice-shop, DefectDojo)", - ) - - -@pytest.fixture -def mock_logger(): - with mock.patch( - "apps.github.management.commands.github_update_related_organizations.logger" - ) as mock_logger: - yield mock_logger - - -@pytest.fixture -def mock_get_github_client(): - with mock.patch( - "apps.github.management.commands.github_update_related_organizations.get_github_client" - ) as mock_get_client: - yield mock_get_client - - def create_mock_organization(login="test-org", num_related_projects=1): org = mock.Mock(spec=Organization) org.login = login @@ -98,7 +65,29 @@ class Scenario(NamedTuple): expected_sync_calls: int -class TestGithubUpdateExternalRepositories: +class TestGithubUpdateRelatedOrganizations: + @pytest.fixture + def command(self): + return Command() + + @pytest.fixture + def mock_logger(self): + with mock.patch( + "apps.github.management.commands.github_update_related_organizations.logger" + ) as mock_logger: + yield mock_logger + + def test_add_arguments(self, command): + """Test that the command's arguments are correctly added.""" + parser = mock.Mock() + command.add_arguments(parser) + parser.add_argument.assert_called_once_with( + "--organization", + required=False, + type=str, + help="The organization name (e.g. juice-shop, DefectDojo)", + ) + @pytest.fixture(autouse=True) def setup(self, monkeypatch, command): monkeypatch.setenv("GITHUB_TOKEN", "valid-token") diff --git a/backend/tests/apps/github/models/mixins/release_test.py b/backend/tests/apps/github/models/mixins/release_test.py index b460c110be..43e27c9178 100644 --- a/backend/tests/apps/github/models/mixins/release_test.py +++ b/backend/tests/apps/github/models/mixins/release_test.py @@ -6,62 +6,62 @@ from apps.github.models.mixins.release import ReleaseIndexMixin -@pytest.fixture -def release_index_mixin_instance(): - instance = ReleaseIndexMixin() - instance.author = MagicMock() +class TestReleaseIndex: + @pytest.fixture + def release_index_mixin_instance(self): + instance = ReleaseIndexMixin() + instance.author = MagicMock() - instance.author.avatar_url = "https://example.com/avatar.png" - instance.author.login = "test_user" - instance.author.name = "Test User" - - instance.repository = MagicMock( - path="mock/repository", - project=MagicMock(nest_key="mock/project"), - ) - instance.created_at = datetime(2023, 1, 1, tzinfo=UTC) - instance.published_at = datetime(2023, 6, 1, tzinfo=UTC) - instance.description = "This is a long description" - instance.is_pre_release = True - instance.name = "Release v1.0.0" - instance.tag_name = "v1.0.0" - return instance + instance.author.avatar_url = "https://example.com/avatar.png" + instance.author.login = "test_user" + instance.author.name = "Test User" + instance.repository = MagicMock( + path="mock/repository", + project=MagicMock(nest_key="mock/project"), + ) + instance.created_at = datetime(2023, 1, 1, tzinfo=UTC) + instance.published_at = datetime(2023, 6, 1, tzinfo=UTC) + instance.description = "This is a long description" + instance.is_pre_release = True + instance.name = "Release v1.0.0" + instance.tag_name = "v1.0.0" + return instance -@pytest.mark.parametrize( - ("attr", "expected"), - [ - ( - "idx_author", - [ - { - "avatar_url": "https://example.com/avatar.png", - "login": "test_user", - "name": "Test User", - } - ], - ), - ("idx_created_at", datetime(2023, 1, 1, tzinfo=UTC).timestamp()), - ( - "idx_description", - "This is a long description", - ), - ("idx_is_pre_release", True), - ("idx_name", "Release v1.0.0"), - ("idx_project", "mock/project"), - ("idx_published_at", datetime(2023, 6, 1, tzinfo=UTC).timestamp()), - ("idx_repository", "mock/repository"), - ("idx_tag_name", "v1.0.0"), - ("idx_author", []), - ("idx_project", ""), - ("idx_published_at", None), - ], -) -def test_release_index(release_index_mixin_instance, attr, expected): - if attr == "idx_author" and not expected: - release_index_mixin_instance.author = None - elif attr == "idx_project" and expected == "": - release_index_mixin_instance.repository.project = None - elif attr == "idx_published_at" and expected is None: - release_index_mixin_instance.published_at = None - assert getattr(release_index_mixin_instance, attr) == expected + @pytest.mark.parametrize( + ("attr", "expected"), + [ + ( + "idx_author", + [ + { + "avatar_url": "https://example.com/avatar.png", + "login": "test_user", + "name": "Test User", + } + ], + ), + ("idx_created_at", datetime(2023, 1, 1, tzinfo=UTC).timestamp()), + ( + "idx_description", + "This is a long description", + ), + ("idx_is_pre_release", True), + ("idx_name", "Release v1.0.0"), + ("idx_project", "mock/project"), + ("idx_published_at", datetime(2023, 6, 1, tzinfo=UTC).timestamp()), + ("idx_repository", "mock/repository"), + ("idx_tag_name", "v1.0.0"), + ("idx_author", []), + ("idx_project", ""), + ("idx_published_at", None), + ], + ) + def test_release_index(self, release_index_mixin_instance, attr, expected): + if attr == "idx_author" and not expected: + release_index_mixin_instance.author = None + elif attr == "idx_project" and expected == "": + release_index_mixin_instance.repository.project = None + elif attr == "idx_published_at" and expected is None: + release_index_mixin_instance.published_at = None + assert getattr(release_index_mixin_instance, attr) == expected diff --git a/backend/tests/apps/github/models/pull_request_test.py b/backend/tests/apps/github/models/pull_request_test.py index 1995d6e2e1..90aa5901f6 100644 --- a/backend/tests/apps/github/models/pull_request_test.py +++ b/backend/tests/apps/github/models/pull_request_test.py @@ -79,58 +79,25 @@ def test_from_github(self, gh_pull_request_mock, pr_attrs, related_objects, expe assert pr.milestone is related_objects["milestone"] assert pr.repository is related_objects["repository"] + @patch("apps.github.models.pull_request.BulkSaveModel.bulk_save") + def test_bulk_save(self, mock_bulk_save): + """Test that bulk_save calls the parent method correctly.""" + pull_requests = [Mock(spec=PullRequest), Mock(spec=PullRequest)] + PullRequest.bulk_save(pull_requests, fields=["title"]) + mock_bulk_save.assert_called_once_with(PullRequest, pull_requests, fields=["title"]) + + @patch("apps.github.models.pull_request.PullRequest.get_node_id") + @patch("apps.github.models.pull_request.PullRequest.objects.get") + def test_update_data_existing_pr(self, mock_get, mock_get_node_id, gh_pull_request_mock): + """Test updating an existing pull request.""" + mock_get_node_id.return_value = "pr_node_id_123" + mock_pr = Mock(spec=PullRequest) + mock_get.return_value = mock_pr + + author = Mock(spec=User, _state=Mock(db=None)) + milestone = Mock(spec=Milestone, _state=Mock(db=None)) + repository = Mock(spec=Repository, _state=Mock(db=None)) -@patch("apps.github.models.pull_request.BulkSaveModel.bulk_save") -def test_bulk_save(mock_bulk_save): - """Test that bulk_save calls the parent method correctly.""" - pull_requests = [Mock(spec=PullRequest), Mock(spec=PullRequest)] - PullRequest.bulk_save(pull_requests, fields=["title"]) - mock_bulk_save.assert_called_once_with(PullRequest, pull_requests, fields=["title"]) - - -@patch("apps.github.models.pull_request.PullRequest.get_node_id") -@patch("apps.github.models.pull_request.PullRequest.objects.get") -def test_update_data_existing_pr(mock_get, mock_get_node_id, gh_pull_request_mock): - """Test updating an existing pull request.""" - mock_get_node_id.return_value = "pr_node_id_123" - mock_pr = Mock(spec=PullRequest) - mock_get.return_value = mock_pr - - author = Mock(spec=User, _state=Mock(db=None)) - milestone = Mock(spec=Milestone, _state=Mock(db=None)) - repository = Mock(spec=Repository, _state=Mock(db=None)) - - pr = PullRequest.update_data( - gh_pull_request_mock, - author=author, - milestone=milestone, - repository=repository, - ) - - mock_get.assert_called_once_with(node_id="pr_node_id_123") - mock_pr.from_github.assert_called_once_with( - gh_pull_request_mock, - author=author, - milestone=milestone, - repository=repository, - ) - mock_pr.save.assert_called_once() - assert pr == mock_pr - - -@patch("apps.github.models.pull_request.PullRequest.get_node_id") -@patch("apps.github.models.pull_request.PullRequest.objects.get") -@patch("apps.github.models.pull_request.PullRequest.from_github") -def test_update_data_new_pr(mock_from_github, mock_get, mock_get_node_id, gh_pull_request_mock): - """Test creating a new pull request.""" - mock_get_node_id.return_value = "pr_node_id_123" - mock_get.side_effect = PullRequest.DoesNotExist - - author = Mock(spec=User, _state=Mock(db=None)) - milestone = Mock(spec=Milestone, _state=Mock(db=None)) - repository = Mock(spec=Repository, _state=Mock(db=None)) - - with patch("apps.github.models.pull_request.PullRequest.save") as mock_save: pr = PullRequest.update_data( gh_pull_request_mock, author=author, @@ -138,59 +105,90 @@ def test_update_data_new_pr(mock_from_github, mock_get, mock_get_node_id, gh_pul repository=repository, ) - mock_get.assert_called_once_with(node_id="pr_node_id_123") - mock_from_github.assert_called_once_with( - gh_pull_request_mock, - author=author, - milestone=milestone, - repository=repository, - ) - mock_save.assert_called_once() - assert pr.node_id == "pr_node_id_123" - + mock_get.assert_called_once_with(node_id="pr_node_id_123") + mock_pr.from_github.assert_called_once_with( + gh_pull_request_mock, + author=author, + milestone=milestone, + repository=repository, + ) + mock_pr.save.assert_called_once() + assert pr == mock_pr + + @patch("apps.github.models.pull_request.PullRequest.get_node_id") + @patch("apps.github.models.pull_request.PullRequest.objects.get") + @patch("apps.github.models.pull_request.PullRequest.from_github") + def test_update_data_new_pr( + self, mock_from_github, mock_get, mock_get_node_id, gh_pull_request_mock + ): + """Test creating a new pull request.""" + mock_get_node_id.return_value = "pr_node_id_123" + mock_get.side_effect = PullRequest.DoesNotExist + + author = Mock(spec=User, _state=Mock(db=None)) + milestone = Mock(spec=Milestone, _state=Mock(db=None)) + repository = Mock(spec=Repository, _state=Mock(db=None)) + + with patch("apps.github.models.pull_request.PullRequest.save") as mock_save: + pr = PullRequest.update_data( + gh_pull_request_mock, + author=author, + milestone=milestone, + repository=repository, + ) + + mock_get.assert_called_once_with(node_id="pr_node_id_123") + mock_from_github.assert_called_once_with( + gh_pull_request_mock, + author=author, + milestone=milestone, + repository=repository, + ) + mock_save.assert_called_once() + assert pr.node_id == "pr_node_id_123" + + def test_pr_save_method(self): + """Test the save method.""" + with patch( + "apps.github.models.generic_issue_model.GenericIssueModel.save" + ) as mock_super_save: + pr = PullRequest() + pr.save() + mock_super_save.assert_called_once_with() + + def test_repository_id(self): + """Test the repository_id property inherited from GenericIssueModel.""" + repository = Mock(spec=Repository, _state=Mock(db=None)) + repository.id = 999 + pr = PullRequest(repository=repository) + assert pr.repository_id == 999 + + @patch("apps.github.models.pull_request.PullRequest.get_node_id") + @patch("apps.github.models.pull_request.PullRequest.objects.get") + def test_update_data_without_save(self, mock_get, mock_get_node_id, gh_pull_request_mock): + """Test update_data with save=False.""" + mock_get_node_id.return_value = "pr_node_id_123" + mock_pr = Mock(spec=PullRequest) + mock_get.return_value = mock_pr + + author = Mock(spec=User, _state=Mock(db=None)) + milestone = Mock(spec=Milestone, _state=Mock(db=None)) + repository = Mock(spec=Repository, _state=Mock(db=None)) -def test_pr_save_method(): - """Test the save method.""" - with patch("apps.github.models.generic_issue_model.GenericIssueModel.save") as mock_super_save: - pr = PullRequest() - pr.save() - mock_super_save.assert_called_once_with() - - -def test_repository_id(): - """Test the repository_id property inherited from GenericIssueModel.""" - repository = Mock(spec=Repository, _state=Mock(db=None)) - repository.id = 999 - pr = PullRequest(repository=repository) - assert pr.repository_id == 999 - - -@patch("apps.github.models.pull_request.PullRequest.get_node_id") -@patch("apps.github.models.pull_request.PullRequest.objects.get") -def test_update_data_without_save(mock_get, mock_get_node_id, gh_pull_request_mock): - """Test update_data with save=False.""" - mock_get_node_id.return_value = "pr_node_id_123" - mock_pr = Mock(spec=PullRequest) - mock_get.return_value = mock_pr - - author = Mock(spec=User, _state=Mock(db=None)) - milestone = Mock(spec=Milestone, _state=Mock(db=None)) - repository = Mock(spec=Repository, _state=Mock(db=None)) - - pr = PullRequest.update_data( - gh_pull_request_mock, - author=author, - milestone=milestone, - repository=repository, - save=False, - ) + pr = PullRequest.update_data( + gh_pull_request_mock, + author=author, + milestone=milestone, + repository=repository, + save=False, + ) - mock_get.assert_called_once_with(node_id="pr_node_id_123") - mock_pr.from_github.assert_called_once_with( - gh_pull_request_mock, - author=author, - milestone=milestone, - repository=repository, - ) - mock_pr.save.assert_not_called() - assert pr == mock_pr + mock_get.assert_called_once_with(node_id="pr_node_id_123") + mock_pr.from_github.assert_called_once_with( + gh_pull_request_mock, + author=author, + milestone=milestone, + repository=repository, + ) + mock_pr.save.assert_not_called() + assert pr == mock_pr diff --git a/backend/tests/apps/mentorship/model/program_admin_test.py b/backend/tests/apps/mentorship/model/program_admin_test.py new file mode 100644 index 0000000000..e53390e20a --- /dev/null +++ b/backend/tests/apps/mentorship/model/program_admin_test.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +from apps.mentorship.models.program_admin import ProgramAdmin + + +class TestProgramAdmin: + def test_str(self): + """Test __str__ returns formatted admin - program (role) string.""" + mock_instance = MagicMock() + mock_instance.admin = "John Doe" + mock_instance.program = "GSoC 2025" + mock_instance.role = ProgramAdmin.AdminRole.OWNER + + result = ProgramAdmin.__str__(mock_instance) + + assert result == "John Doe - GSoC 2025 (owner)" diff --git a/backend/tests/apps/owasp/api/internal/queries/sponsor_test.py b/backend/tests/apps/owasp/api/internal/queries/sponsor_test.py new file mode 100644 index 0000000000..2c60585bd2 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/queries/sponsor_test.py @@ -0,0 +1,25 @@ +from unittest.mock import MagicMock, patch + +from apps.owasp.api.internal.queries.sponsor import SponsorQuery +from apps.owasp.models.sponsor import Sponsor + + +class TestSponsorQuery: + def test_sponsors_returns_sorted_by_type(self): + """Test sponsors resolver sorts by sponsor type priority.""" + diamond = MagicMock() + diamond.sponsor_type = Sponsor.SponsorType.DIAMOND + + silver = MagicMock() + silver.sponsor_type = Sponsor.SponsorType.SILVER + + platinum = MagicMock() + platinum.sponsor_type = Sponsor.SponsorType.PLATINUM + + with patch.object(Sponsor.objects, "all", return_value=[silver, diamond, platinum]): + query = SponsorQuery() + result = list(query.sponsors()) + + assert result[0] == diamond + assert result[1] == platinum + assert result[2] == silver diff --git a/backend/tests/apps/owasp/index/registry/chapter_test.py b/backend/tests/apps/owasp/index/registry/chapter_test.py new file mode 100644 index 0000000000..aecbc07b50 --- /dev/null +++ b/backend/tests/apps/owasp/index/registry/chapter_test.py @@ -0,0 +1,19 @@ +from unittest.mock import patch + +from apps.owasp.index.registry.chapter import ChapterIndex + + +class TestChapterIndex: + def test_get_entities(self): + """Test get_entities returns active chapters with select_related.""" + with patch.object(ChapterIndex, "__init__", lambda _: None): + index = ChapterIndex() + + with patch("apps.owasp.index.registry.chapter.Chapter") as mock_chapter: + mock_manager = mock_chapter.active_chapters + mock_manager.select_related.return_value = ["chapter1", "chapter2"] + + result = index.get_entities() + + mock_manager.select_related.assert_called_once_with("owasp_repository") + assert result == ["chapter1", "chapter2"] diff --git a/backend/tests/apps/owasp/index/registry/committee_test.py b/backend/tests/apps/owasp/index/registry/committee_test.py new file mode 100644 index 0000000000..6ef83d9038 --- /dev/null +++ b/backend/tests/apps/owasp/index/registry/committee_test.py @@ -0,0 +1,19 @@ +from unittest.mock import patch + +from apps.owasp.index.registry.committee import CommitteeIndex + + +class TestCommitteeIndex: + def test_get_entities(self): + """Test get_entities returns active committees with select_related.""" + with patch.object(CommitteeIndex, "__init__", lambda _: None): + index = CommitteeIndex() + + with patch("apps.owasp.index.registry.committee.Committee") as mock_committee: + mock_manager = mock_committee.active_committees + mock_manager.select_related.return_value = ["committee1", "committee2"] + + result = index.get_entities() + + mock_manager.select_related.assert_called_once_with("owasp_repository") + assert result == ["committee1", "committee2"] diff --git a/backend/tests/apps/owasp/index/search/chapter_test.py b/backend/tests/apps/owasp/index/search/chapter_test.py index 9e892c443f..0236e7493b 100644 --- a/backend/tests/apps/owasp/index/search/chapter_test.py +++ b/backend/tests/apps/owasp/index/search/chapter_test.py @@ -13,19 +13,32 @@ } -@pytest.mark.parametrize( - ("query", "page", "expected_hits"), - [ - ("security", "1", MOCKED_HITS), - ("web", "2", MOCKED_HITS), - ("", "1", MOCKED_HITS), - ], -) -def test_get_chapters(query, page, expected_hits): - with patch( - "apps.owasp.index.search.chapter.raw_search", return_value=expected_hits - ) as mock_raw_search: - result = get_chapters(query=query, page=int(page)) - - mock_raw_search.assert_called_once() - assert result == expected_hits +class TestGetChapters: + @pytest.mark.parametrize( + ("query", "page", "expected_hits"), + [ + ("security", "1", MOCKED_HITS), + ("web", "2", MOCKED_HITS), + ("", "1", MOCKED_HITS), + ], + ) + def test_get_chapters(self, query, page, expected_hits): + with patch( + "apps.owasp.index.search.chapter.raw_search", return_value=expected_hits + ) as mock_raw_search: + result = get_chapters(query=query, page=int(page)) + + mock_raw_search.assert_called_once() + assert result == expected_hits + + def test_get_chapters_with_searchable_attributes(self): + with patch( + "apps.owasp.index.search.chapter.raw_search", return_value=MOCKED_HITS + ) as mock_raw_search: + result = get_chapters(query="test", searchable_attributes=["idx_name"]) + + mock_raw_search.assert_called_once() + call_args = mock_raw_search.call_args[0] + params = call_args[2] + assert params["restrictSearchableAttributes"] == ["idx_name"] + assert result == MOCKED_HITS diff --git a/backend/tests/apps/owasp/index/search/committee_test.py b/backend/tests/apps/owasp/index/search/committee_test.py index 07ee49098c..10029c329d 100644 --- a/backend/tests/apps/owasp/index/search/committee_test.py +++ b/backend/tests/apps/owasp/index/search/committee_test.py @@ -19,19 +19,20 @@ } -@pytest.mark.parametrize( - ("query", "page", "expected_hits"), - [ - ("security", 1, MOCKED_HITS), - ("web", 2, MOCKED_HITS), - ("", 1, MOCKED_HITS), - ], -) -def test_get_committees(query, page, expected_hits): - with patch( - "apps.owasp.index.search.committee.raw_search", return_value=expected_hits - ) as mock_raw_search: - result = get_committees(query=query, page=page) +class TestGetCommittees: + @pytest.mark.parametrize( + ("query", "page", "expected_hits"), + [ + ("security", 1, MOCKED_HITS), + ("web", 2, MOCKED_HITS), + ("", 1, MOCKED_HITS), + ], + ) + def test_get_committees(self, query, page, expected_hits): + with patch( + "apps.owasp.index.search.committee.raw_search", return_value=expected_hits + ) as mock_raw_search: + result = get_committees(query=query, page=page) - mock_raw_search.assert_called_once() - assert result == expected_hits + mock_raw_search.assert_called_once() + assert result == expected_hits diff --git a/backend/tests/apps/owasp/index/search/issue_test.py b/backend/tests/apps/owasp/index/search/issue_test.py index a035b2ba08..7a19feddde 100644 --- a/backend/tests/apps/owasp/index/search/issue_test.py +++ b/backend/tests/apps/owasp/index/search/issue_test.py @@ -23,19 +23,32 @@ } -@pytest.mark.parametrize( - ("query", "page", "expected_hits"), - [ - ("security", 1, MOCKED_HITS), - ("web", 2, MOCKED_HITS), - ("", 1, MOCKED_HITS), - ], -) -def test_get_issues(query, page, expected_hits): - with patch( - "apps.owasp.index.search.issue.raw_search", return_value=expected_hits - ) as mock_raw_search: - result = get_issues(query, page=page, distinct=False) - - mock_raw_search.assert_called_once() - assert result == expected_hits +class TestGetIssues: + @pytest.mark.parametrize( + ("query", "page", "expected_hits"), + [ + ("security", 1, MOCKED_HITS), + ("web", 2, MOCKED_HITS), + ("", 1, MOCKED_HITS), + ], + ) + def test_get_issues(self, query, page, expected_hits): + with patch( + "apps.owasp.index.search.issue.raw_search", return_value=expected_hits + ) as mock_raw_search: + result = get_issues(query, page=page, distinct=False) + + mock_raw_search.assert_called_once() + assert result == expected_hits + + def test_get_issues_with_distinct(self): + with patch( + "apps.owasp.index.search.issue.raw_search", return_value=MOCKED_HITS + ) as mock_raw_search: + result = get_issues("test", distinct=True) + + mock_raw_search.assert_called_once() + call_args = mock_raw_search.call_args[0] + params = call_args[2] + assert params["distinct"] == 1 + assert result == MOCKED_HITS diff --git a/backend/tests/apps/owasp/index/search/project_test.py b/backend/tests/apps/owasp/index/search/project_test.py index b9fd40bf09..b58850d962 100644 --- a/backend/tests/apps/owasp/index/search/project_test.py +++ b/backend/tests/apps/owasp/index/search/project_test.py @@ -13,19 +13,32 @@ } -@pytest.mark.parametrize( - ("query", "page", "expected_hits"), - [ - ("security", 1, MOCKED_HITS), - ("web", 2, MOCKED_HITS), - ("", 1, MOCKED_HITS), - ], -) -def test_get_projects(query, page, expected_hits): - with patch( - "apps.owasp.index.search.project.raw_search", return_value=expected_hits - ) as mock_raw_search: - result = get_projects(query=query, page=page) - - mock_raw_search.assert_called_once() - assert result == expected_hits +class TestGetProjects: + @pytest.mark.parametrize( + ("query", "page", "expected_hits"), + [ + ("security", 1, MOCKED_HITS), + ("web", 2, MOCKED_HITS), + ("", 1, MOCKED_HITS), + ], + ) + def test_get_projects(self, query, page, expected_hits): + with patch( + "apps.owasp.index.search.project.raw_search", return_value=expected_hits + ) as mock_raw_search: + result = get_projects(query=query, page=page) + + mock_raw_search.assert_called_once() + assert result == expected_hits + + def test_get_projects_with_searchable_attributes(self): + with patch( + "apps.owasp.index.search.project.raw_search", return_value=MOCKED_HITS + ) as mock_raw_search: + result = get_projects(query="test", searchable_attributes=["idx_name"]) + + mock_raw_search.assert_called_once() + call_args = mock_raw_search.call_args[0] + params = call_args[2] + assert params["restrictSearchableAttributes"] == ["idx_name"] + assert result == MOCKED_HITS diff --git a/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py b/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py index 20c929d57a..9385e62230 100644 --- a/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py +++ b/backend/tests/apps/owasp/management/commands/common/entity_metadata_test.py @@ -96,3 +96,8 @@ def test_handle_validation_failure(self, mock_get_schema, mock_validate): error_output = stderr.getvalue() assert "Validation FAILED" in error_output assert "Validation error: missing required field" in error_output + + def test_get_metadata_abstract_returns_empty_dict(self): + """Test that the abstract get_metadata base implementation returns empty dict.""" + result = EntityMetadataBase.get_metadata(None, entity=MagicMock()) + assert result == {} diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py index a46db8b373..e76bba2dbe 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py @@ -1,5 +1,7 @@ """Test cases for owasp_aggregate_entity_contributions management command.""" +import io +from argparse import ArgumentParser from datetime import UTC, datetime, timedelta from unittest import mock @@ -55,6 +57,28 @@ class TestOwaspAggregateContributions: def command(self): return Command() + def test_add_arguments(self, command): + """Test add_arguments adds expected arguments.""" + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args(["--entity-type", "chapter"]) + assert args.entity_type == "chapter" + assert args.days == 365 + assert args.key is None + assert args.offset == 0 + + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + def test_handle_empty_entities(self, mock_chapter_model, command): + """Test handle with empty entities list.""" + mock_chapter_model.objects.filter.return_value = MockQuerySet([]) + mock_chapter_model.bulk_save = mock.Mock() + mock_chapter_model._meta = mock.Mock() + mock_chapter_model._meta.verbose_name_plural = "chapters" + + command.handle(entity_type="chapter", days=365, offset=0) + + mock_chapter_model.bulk_save.assert_not_called() + @pytest.fixture def mock_chapter(self): chapter = mock.Mock(spec=Chapter) @@ -248,7 +272,7 @@ def test_handle_chapters_only( command.handle(entity_type="chapter", days=365, offset=0) assert mock_chapter.contribution_data == {"2024-11-16": 5} - assert mock_chapter_model.bulk_save.called + mock_chapter_model.bulk_save.assert_called() @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") @@ -285,7 +309,7 @@ def test_handle_projects_only( assert mock_project.contribution_data == {"2024-11-16": 10} assert mock_project.contribution_stats is not None assert "commits" in mock_project.contribution_stats - assert mock_project_model.bulk_save.called + mock_project_model.bulk_save.assert_called() @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") @@ -325,8 +349,8 @@ def test_handle_both_entities( command.handle(entity_type="chapter", days=365, offset=0) command.handle(entity_type="project", days=365, offset=0) - assert mock_chapter_model.bulk_save.called - assert mock_project_model.bulk_save.called + mock_chapter_model.bulk_save.assert_called() + mock_project_model.bulk_save.assert_called() @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") @@ -435,7 +459,7 @@ def test_handle_custom_days( command.handle(entity_type="chapter", days=90, offset=0) # Verify aggregate was called with correct start_date. - assert mock_aggregate.called + mock_aggregate.assert_called() call_args = mock_aggregate.call_args[0] start_date = call_args[1] expected_start = datetime.now(tz=UTC) - timedelta(days=90) @@ -524,3 +548,12 @@ def test_handle_project_with_offset( command.handle(entity_type="project", offset=2, days=365) assert mock_aggregate.call_count == 1 mock_project_model.bulk_save.assert_called_once() + + def test_handle_invalid_entity_type(self, command): + """Test handle with invalid entity_type.""" + command.stdout = io.StringIO() + command.style = mock.Mock() + command.style.SUCCESS = lambda msg: msg + command.handle(entity_type="invalid", days=365, offset=0) + output = command.stdout.getvalue() + assert "Done!" in output diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py index e61d5dfefa..135185a392 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py @@ -1,5 +1,6 @@ """Tests for community member contributions aggregation command.""" +from argparse import ArgumentParser from datetime import UTC, datetime, timedelta from unittest import mock @@ -44,6 +45,16 @@ def annotate(self, **kwargs): class TestAggregateContributionsCommand: """Test suite for community member contributions aggregation command.""" + def test_add_arguments(self): + """Test add_arguments adds expected arguments.""" + command = Command() + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args([]) + assert args.user is None + assert args.days == 365 + assert args.batch_size == 100 + def test_aggregate_user_contributions_empty(self): """Test aggregation with no contributions.""" command = Command() @@ -154,7 +165,7 @@ def test_handle_with_specific_user_found(self): command.handle(user="testuser", days=365, batch_size=100) assert mock_user.contribution_data == {"2024-01-01": 5} - assert mock_user_model.bulk_save.called + mock_user_model.bulk_save.assert_called() call_args = mock_user_model.bulk_save.call_args assert call_args[0][0] == [mock_user] assert call_args[1]["fields"] == ["contribution_data"] @@ -210,7 +221,7 @@ def test_handle_all_users(self): assert mock_user1.contribution_data == {"2024-01-01": 5} assert mock_user2.contribution_data == {"2024-01-01": 5} - assert mock_user_model.bulk_save.called + mock_user_model.bulk_save.assert_called() call_args = mock_user_model.bulk_save.call_args assert len(call_args[0][0]) == 2 assert mock_user1 in call_args[0][0] @@ -246,7 +257,7 @@ def test_handle_custom_days_parameter(self): command.handle(user=None, days=90, batch_size=100) - assert mock_aggregate.called + mock_aggregate.assert_called() call_args = mock_aggregate.call_args[0] start_date = call_args[1] expected_start = datetime(2024, 1, 31, tzinfo=UTC) - timedelta(days=90) diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py index c3474d0aa0..fa66379528 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_projects_test.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from unittest import mock import pytest @@ -17,6 +18,13 @@ class TestOwaspAggregateProjects: def command(self): return Command() + def test_add_arguments(self, command): + """Test add_arguments adds expected arguments.""" + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args([]) + assert args.offset == 0 + @pytest.fixture def mock_project(self): project = mock.Mock(spec=Project) @@ -79,7 +87,7 @@ def test_handle(self, mock_bulk_save, command, mock_project, offset, projects): command.stdout = mock.MagicMock() command.handle(offset=offset) - assert mock_bulk_save.called + mock_bulk_save.assert_called() assert command.stdout.write.call_count == projects - offset for call in command.stdout.write.call_args_list: @@ -129,7 +137,7 @@ def test_handle_deactivates_archived_project(self, mock_bulk_save, command, mock command.handle(offset=0) assert not mock_project.is_active - assert mock_bulk_save.called + mock_bulk_save.assert_called() @mock.patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) @mock.patch.object(Project, "bulk_save", autospec=True) @@ -168,4 +176,4 @@ def test_handle_no_release_or_license(self, mock_bulk_save, command, mock_projec with mock.patch.object(Project, "active_projects", mock_active_projects): command.stdout = mock.MagicMock() command.handle(offset=0) - assert mock_bulk_save.called + mock_bulk_save.assert_called() diff --git a/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py b/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py index 6bd04dfffa..7723b7a976 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_create_member_snapshot_test.py @@ -1,4 +1,5 @@ import io +from argparse import ArgumentParser from datetime import UTC, datetime from unittest import mock @@ -12,6 +13,15 @@ class TestOwaspCreateMemberSnapshotCommand: def command(self): return Command() + def test_add_arguments(self, command): + """Test add_arguments adds expected arguments.""" + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args(["testuser"]) + assert args.username == "testuser" + assert args.start_at is None + assert args.end_at is None + def test_parse_date_valid(self, command): result = command.parse_date("2025-01-15", datetime(2025, 1, 1, tzinfo=UTC)) @@ -501,6 +511,85 @@ def test_generate_repository_contributions_empty_commits(self, command): assert result == {} + def test_generate_heatmap_data_empty_issues(self, command): + """Test generate_heatmap_data with issue outside date range.""" + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 1, 3, tzinfo=UTC) + + mock_commit = mock.Mock() + mock_commit.created_at = datetime(2025, 1, 1, tzinfo=UTC) + + mock_issue_out_of_range = mock.Mock() + mock_issue_out_of_range.created_at = datetime(2025, 2, 1, tzinfo=UTC) + + result = command.generate_heatmap_data( + [mock_commit], [], [mock_issue_out_of_range], start_at, end_at + ) + + assert result["2025-01-01"] == 1 + assert result["2025-01-02"] == 0 + + def test_generate_entity_contributions_unmatched_repos(self, command): + """Test entity_contributions where contributions' repos don't match led entities.""" + start_at = datetime(2025, 1, 1, tzinfo=UTC) + end_at = datetime(2025, 10, 1, tzinfo=UTC) + + mock_user = mock.Mock() + mock_user.id = 1 + + mock_commit = mock.Mock() + mock_commit.created_at = datetime(2025, 3, 1, tzinfo=UTC) + mock_commit.repository_id = 999 + + mock_pr = mock.Mock() + mock_pr.created_at = datetime(2025, 3, 1, tzinfo=UTC) + mock_pr.repository_id = 999 + + mock_issue = mock.Mock() + mock_issue.created_at = datetime(2025, 3, 1, tzinfo=UTC) + mock_issue.repository_id = 999 + + mock_commits = mock.Mock() + mock_commits.select_related.return_value = [mock_commit] + mock_commits.__iter__ = lambda _: iter([mock_commit]) + + mock_prs = mock.Mock() + mock_prs.select_related.return_value = [mock_pr] + mock_prs.__iter__ = lambda _: iter([mock_pr]) + + mock_issues = mock.Mock() + mock_issues.select_related.return_value = [mock_issue] + mock_issues.__iter__ = lambda _: iter([mock_issue]) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_create_member_snapshot.Chapter" + ) as mock_chapter_model, + mock.patch( + "apps.owasp.management.commands.owasp_create_member_snapshot.ContentType" + ) as mock_content_type, + mock.patch( + "apps.owasp.management.commands.owasp_create_member_snapshot.EntityMember" + ) as mock_entity_member, + ): + mock_content_type.objects.get_for_model.return_value = mock.Mock(id=1) + mock_entity_member.objects.filter.return_value.values_list.return_value = [1] + + mock_chapter = mock.Mock() + mock_chapter.nest_key = "test-chapter" + mock_chapter.owasp_repository_id = 100 + + mock_filter = mock.Mock() + mock_filter.select_related.return_value = [mock_chapter] + mock_filter.__iter__ = lambda _: iter([mock_chapter]) + mock_chapter_model.objects.filter.return_value = mock_filter + + result = command.generate_entity_contributions( + mock_user, mock_commits, mock_prs, mock_issues, "chapter", start_at, end_at + ) + + assert result == {"test-chapter": 0} + class TestHandleMethod: """Tests for the handle() method of owasp_create_member_snapshot command.""" @@ -837,6 +926,54 @@ def test_handle_no_contributions_warning(self, command, mocker): stdout_calls = [str(call) for call in command.stdout.write.call_args_list] assert any("no contributions" in str(call).lower() for call in stdout_calls) + def test_handle_with_slack_zero_messages(self, command, mocker): + """Test handle with valid Slack ID but zero messages.""" + mock_user = mocker.patch(f"{self.target_module}.User") + mock_user_instance = mock.MagicMock() + mock_user.objects.get.return_value = mock_user_instance + + mock_snapshot_model = mocker.patch(f"{self.target_module}.MemberSnapshot") + mock_snapshot_model.objects.filter.return_value.first.return_value = None + + mock_snapshot = mock.MagicMock() + mock_snapshot.id = 1 + mock_snapshot.total_contributions = 0 + mock_snapshot.commits_count = 0 + mock_snapshot.pull_requests_count = 0 + mock_snapshot.issues_count = 0 + mock_snapshot.messages_count = 0 + mock_snapshot_model.objects.create.return_value = mock_snapshot + + mock_commit = mocker.patch(f"{self.target_module}.Commit") + mock_commit.objects.filter.return_value.count.return_value = 0 + + mock_pr = mocker.patch(f"{self.target_module}.PullRequest") + mock_pr.objects.filter.return_value.count.return_value = 0 + + mock_issue = mocker.patch(f"{self.target_module}.Issue") + mock_issue.objects.filter.return_value.count.return_value = 0 + + mock_profile = mocker.patch(f"{self.target_module}.MemberProfile") + mock_profile_instance = mock.MagicMock() + mock_profile_instance.owasp_slack_id = "U12345" + mock_profile.objects.get.return_value = mock_profile_instance + + mock_message = mocker.patch(f"{self.target_module}.Message") + mock_msg_qs = mock.MagicMock() + mock_msg_qs.select_related.return_value = mock_msg_qs + mock_msg_qs.count.return_value = 0 + mock_message.objects.filter.return_value = mock_msg_qs + mock_message.objects.none.return_value = mock_msg_qs + + mocker.patch.object(command, "generate_heatmap_data", return_value={}) + mocker.patch.object(command, "generate_entity_contributions", return_value={}) + mocker.patch.object(command, "generate_repository_contributions", return_value={}) + mocker.patch.object(command, "generate_communication_heatmap_data", return_value={}) + + command.handle(username="testuser", start_at=None, end_at=None) + + mock_snapshot.messages.add.assert_not_called() + class TestGenerateEntityContributionsChapter: """Tests for chapter entity contributions.""" diff --git a/backend/tests/apps/owasp/management/commands/owasp_create_project_metadata_file_test.py b/backend/tests/apps/owasp/management/commands/owasp_create_project_metadata_file_test.py index b4de8eb182..e66312f54f 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_create_project_metadata_file_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_create_project_metadata_file_test.py @@ -144,3 +144,27 @@ def test_get_metadata_with_partially_filled_related_objects(self, mock_project): "url": "https://github.com/owasp/partial", } assert "description" not in metadata["repositories"][0] + + def test_get_metadata_leader_with_no_fields(self, mock_project): + """Test leader with no login, no name, no email.""" + command = Command() + leader_empty = MagicMock() + leader_empty.login = None + leader_empty.name = None + leader_empty.email = None + mock_project.leaders.all.return_value = [leader_empty] + + metadata = command.get_metadata(mock_project) + assert metadata["leaders"] == [{}] + + def test_get_metadata_repo_with_no_fields(self, mock_project): + """Test repo with no description, name, or url.""" + command = Command() + repo_empty = MagicMock() + repo_empty.description = None + repo_empty.name = None + repo_empty.url = None + mock_project.repositories.all.return_value = [repo_empty] + + metadata = command.get_metadata(mock_project) + assert metadata["repositories"] == [] diff --git a/backend/tests/apps/owasp/management/commands/owasp_enrich_chapters_test.py b/backend/tests/apps/owasp/management/commands/owasp_enrich_chapters_test.py index 7ccdd1447e..9f92f9c8e5 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_enrich_chapters_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_enrich_chapters_test.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from unittest import mock import pytest @@ -14,6 +15,13 @@ class TestOwaspEnrichChapters: def command(self): return Command() + def test_add_arguments(self, command): + """Test add_arguments adds expected arguments.""" + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args([]) + assert args.offset == 0 + @pytest.fixture def mock_chapter(self): chapter = mock.Mock(spec=Chapter) @@ -24,6 +32,18 @@ def mock_chapter(self): chapter.longitude = None return chapter + @staticmethod + def _make_active_chapters_qs(chapters_list): + """Create a mock active chapters queryset from a list of chapter mocks.""" + mock_active_chapters = mock.MagicMock() + mock_active_chapters.__iter__.side_effect = lambda: iter(chapters_list) + mock_active_chapters.count.return_value = len(chapters_list) + mock_active_chapters.__getitem__.side_effect = lambda idx: ( + chapters_list[idx.start : idx.stop] if isinstance(idx, slice) else chapters_list[idx] + ) + mock_active_chapters.without_geo_data.order_by.return_value = mock_active_chapters + return mock_active_chapters + @pytest.mark.parametrize( ("offset", "chapters", "latitude", "longitude"), [ @@ -53,16 +73,7 @@ def test_handle( ) mock_chapters_list = [mock_chapter] * chapters - - mock_active_chapters = mock.MagicMock() - mock_active_chapters.__iter__.return_value = iter(mock_chapters_list) - mock_active_chapters.count.return_value = len(mock_chapters_list) - mock_active_chapters.__getitem__.side_effect = lambda idx: ( - mock_chapters_list[idx.start : idx.stop] - if isinstance(idx, slice) - else mock_chapters_list[idx] - ) - mock_active_chapters.without_geo_data.order_by.return_value = mock_active_chapters + mock_active_chapters = self._make_active_chapters_qs(mock_chapters_list) with ( mock.patch.object(Chapter, "active_chapters", mock_active_chapters), @@ -76,7 +87,7 @@ def test_handle( mock_active_chapters.count.assert_called_once() - assert mock_bulk_save.called + mock_bulk_save.assert_called() assert command.stdout.write.call_count == len(mock_chapters_list) - offset @@ -89,3 +100,34 @@ def test_handle( assert chapter.suggested_location == "Suggested location" assert chapter.latitude == latitude assert chapter.longitude == longitude + + @mock.patch.dict("os.environ", {"GEO_API_KEY": "test-token"}) + @mock.patch.object(Chapter, "bulk_save", autospec=True) + def test_handle_geo_exception(self, mock_bulk_save, command, mock_chapter): + """Test handle when generate_geo_location raises an exception.""" + mock_prompt = mock.Mock() + mock_prompt.get_owasp_chapter_summary.return_value = "summary prompt" + + mock_chapter.generate_summary.side_effect = lambda **_: setattr( + mock_chapter, "summary", "Generated summary" + ) + mock_chapter.generate_suggested_location.side_effect = lambda: setattr( + mock_chapter, "suggested_location", "Suggested location" + ) + mock_chapter.generate_geo_location.side_effect = Exception("Geo API error") + + mock_chapters_list = [mock_chapter] + mock_active_chapters = self._make_active_chapters_qs(mock_chapters_list) + + with ( + mock.patch.object(Chapter, "active_chapters", mock_active_chapters), + mock.patch.object( + Prompt, "get_owasp_chapter_summary", mock_prompt.get_owasp_chapter_summary + ), + mock.patch("time.sleep", return_value=None), + ): + command.stdout = mock.MagicMock() + command.handle(offset=0) + + mock_bulk_save.assert_called() + mock_chapter.generate_geo_location.assert_called_once() diff --git a/backend/tests/apps/owasp/management/commands/owasp_enrich_committees_test.py b/backend/tests/apps/owasp/management/commands/owasp_enrich_committees_test.py index 72ce0a60b9..14d4d1d90a 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_enrich_committees_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_enrich_committees_test.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from unittest import mock import pytest @@ -14,6 +15,15 @@ class TestOwaspEnrichCommittees: def command(self): return Command() + def test_add_arguments(self, command): + """Test add_arguments adds expected arguments.""" + parser = ArgumentParser() + command.add_arguments(parser) + args = parser.parse_args([]) + assert args.offset == 0 + assert not args.force_update_summary + assert args.update_summary + @pytest.fixture def mock_committee(self): committee = mock.Mock(spec=Committee) @@ -96,7 +106,7 @@ def test_handle( else: mock_active_committees_without_summary.count.assert_called_once() - assert mock_bulk_save.called + mock_bulk_save.assert_called() assert command.stdout.write.call_count == committees - offset diff --git a/backend/tests/apps/owasp/management/commands/owasp_scrape_projects_test.py b/backend/tests/apps/owasp/management/commands/owasp_scrape_projects_test.py index 692446fea2..be4809fa68 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_scrape_projects_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_scrape_projects_test.py @@ -274,3 +274,47 @@ def test_handle_skipped_related_url(self, mock_github, mock_bulk_save, command): assert mock_project.related_urls == [] assert mock_project.invalid_urls == [] assert mock_project.get_related_url.call_count == 2 + + @mock.patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}) + @mock.patch.object(Project, "bulk_save", autospec=True) + @mock.patch("apps.owasp.management.commands.owasp_scrape_projects.get_github_client") + def test_handle_github_user_org_repos_success(self, mock_github, mock_bulk_save, command): + """Test handle when GITHUB_USER_RE matches and get_organization succeeds.""" + mock_project = mock.Mock(spec=Project) + mock_project.owasp_url = "https://owasp.org/www-project-test" + mock_project.github_url = "https://github.com/owasp/test-project" + mock_project.get_audience.return_value = [] + mock_project.get_urls.return_value = ["https://github.com/some-org"] + mock_project.get_leaders_emails.return_value = {} + mock_project.get_related_url.side_effect = lambda url, **_: url + + mock_scraper = mock.Mock(spec=OwaspScraper) + mock_scraper.page_tree = True + mock_scraper.verify_url.return_value = "https://github.com/some-org" + + mock_gh = mock.Mock() + mock_repo = mock.Mock() + mock_repo.full_name = "some-org/repo1" + mock_gh.get_organization.return_value.get_repos.return_value = [mock_repo] + mock_github.return_value = mock_gh + + mock_projects_list = [mock_project] + mock_active_projects = mock.MagicMock() + mock_active_projects.__iter__.return_value = iter(mock_projects_list) + mock_active_projects.count.return_value = 1 + mock_active_projects.__getitem__.return_value = mock_projects_list + mock_active_projects.order_by.return_value = mock_active_projects + + command.stdout = mock.MagicMock() + with ( + mock.patch.object(Project, "active_projects", mock_active_projects), + mock.patch("time.sleep"), + mock.patch( + "apps.owasp.management.commands.owasp_scrape_projects.OwaspScraper", + return_value=mock_scraper, + ), + ): + command.handle(offset=0) + + mock_gh.get_organization.assert_called_once_with("some-org") + assert mock_project.related_urls == ["https://github.com/some-org/repo1"] diff --git a/backend/tests/apps/owasp/management/commands/owasp_sync_posts_test.py b/backend/tests/apps/owasp/management/commands/owasp_sync_posts_test.py index 7e167007bc..6c0115a617 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_sync_posts_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_sync_posts_test.py @@ -214,3 +214,26 @@ def test_handle_missing_required_fields( mock_update_data.assert_not_called() mock_bulk_save.assert_called_once_with([]) command.stderr.write.assert_called() + + @mock.patch("apps.owasp.management.commands.owasp_sync_posts.get_repository_file_content") + @mock.patch("apps.owasp.models.post.Post.update_data") + @mock.patch("apps.owasp.models.post.Post.bulk_save") + def test_handle_no_yaml_match( + self, mock_bulk_save, mock_update_data, mock_get_content, command + ): + """Test handle when content starts with --- but yaml_pattern doesn't match.""" + repository_files = [ + { + "name": "2023-01-01-no-yaml.md", + "download_url": "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_posts/2023-01-01-no-yaml.md", + }, + ] + no_yaml_match_content = "---\n---\nContent without valid YAML block" + + mock_get_content.side_effect = [json.dumps(repository_files), no_yaml_match_content] + + command.handle() + + assert mock_get_content.call_count == 2 + mock_update_data.assert_not_called() + mock_bulk_save.assert_called_once_with([]) diff --git a/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py b/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py index e73dc397f4..a89ebee968 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_update_leaders_test.py @@ -75,9 +75,9 @@ def test_command_updates_members_with_correct_matches( assert filter_call[1]["member__isnull"] # Verify all members were matched and saved - assert mock_members[0].save.called - assert mock_members[1].save.called - assert mock_members[2].save.called + mock_members[0].save.assert_called_once() + mock_members[1].save.assert_called_once() + mock_members[2].save.assert_called_once() # Verify correct member_id assignments assert mock_members[0].member_id == 2 # jane.doe @@ -113,7 +113,7 @@ def test_fuzzy_match_below_threshold( call_command("owasp_update_leaders", "chapter", "--threshold=95", stdout=out) # Verify no matches were made - assert not mock_members[0].save.called + mock_members[0].save.assert_not_called() assert "No match found for 'Jone Doe'" in out.getvalue() assert "1 leaders remain unmatched" in out.getvalue() @@ -171,7 +171,7 @@ def test_exact_match_is_preferred_over_fuzzy( mock_fuzz.token_sort_ratio.assert_not_called() # Verify member was matched and saved - assert mock_members[0].save.called + mock_members[0].save.assert_called_once() assert mock_members[0].member_id == 1 # john.doe assert "Matched 1 out of 1 Chapter leaders" in out.getvalue() @@ -241,6 +241,33 @@ def test_find_best_user_match_priority(self): match = cmd.find_best_user_match("target", "target", users[1:], 0) assert match == users[1] + def test_handle_invalid_model_direct(self): + """Test handle with invalid model_name.""" + cmd = TestOwaspUpdateLeaders._create_command() + cmd.stdout = io.StringIO() + with patch(f"{COMMAND_PATH}.User"): + cmd.handle(model_name="invalid", threshold=75) + output = cmd.stdout.getvalue() + assert "Invalid model name" in output + + def test_find_best_user_match_fuzzy_user_no_name(self): + """Test fuzzy matching with user that has no name.""" + cmd = TestOwaspUpdateLeaders._create_command() + users = [ + {"id": 1, "login": "some_long_login", "name": None, "email": "no@email.com"}, + ] + match = cmd.find_best_user_match("some_long_login_x", None, users, 75) + assert match == users[0] + + def test_find_best_user_match_fuzzy_login_wins(self): + """Test fuzzy matching where login score is the best.""" + cmd = TestOwaspUpdateLeaders._create_command() + users = [ + {"id": 1, "login": "alice_smith", "name": "Different Person", "email": "x@x.com"}, + ] + match = cmd.find_best_user_match("alice_smith_x", None, users, 50) + assert match == users[0] + @staticmethod def _create_command(): return Command() diff --git a/backend/tests/apps/owasp/models/common_test.py b/backend/tests/apps/owasp/models/common_test.py index 1cf140f954..a276ae5a9e 100644 --- a/backend/tests/apps/owasp/models/common_test.py +++ b/backend/tests/apps/owasp/models/common_test.py @@ -276,11 +276,16 @@ def test_get_metadata(self, content, expected_metadata): ["https://example.com", "https://test.org"], ), ( - """* [Broken](https://) + """* [Broken](https://.-invalid) * [Valid](https://example.com)""", None, ["https://example.com"], ), + ( + """Visit https://example.com and also see https://.-invalid""", + None, + ["https://example.com"], + ), ("This test contains no URLs.", None, []), ("", None, []), (None, None, []), @@ -660,7 +665,7 @@ def test_get_leaders_emails_name_without_email(self): assert "Leader With Email" in leaders assert leaders["Leader With Email"] == "email@example.com" assert "Another Leader" in leaders - assert leaders["Another Leader"] == "https://example.com" + assert leaders["Another Leader"] is None def test_get_urls_no_content(self): """Test get_urls returns empty list when content is None.""" diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index d3583c4898..fc3c1d3c23 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -89,6 +89,11 @@ def test_parse_dates_no_year_crossover(self): result = Event.parse_dates("May 1 - May 5", date(2025, 5, 1)) assert result == date(2025, 5, 5) + def test_parse_dates_day_with_year_suffix_no_comma(self): + """Test parse_dates where end_str has day match, >2 chars, and no comma.""" + result = Event.parse_dates("26-30th", date(2025, 5, 26)) + assert result == date(2025, 5, 30) + def test_update_data_existing_event(self): """Test update_data when the event already exists.""" category = "Global" @@ -231,6 +236,26 @@ def test_update_data_keyerror_returns_none(self): assert result is None + def test_update_data_without_save(self): + """Test update_data with save=False skips event.save().""" + category = "Global" + data = {"name": "No Save 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, + patch.object(Event, "save") as mock_save, + ): + mock_slugify.return_value = "no-save-event" + mock_get.side_effect = Event.DoesNotExist + + result = Event.update_data(category, data, save=False) + + assert result is not None + mock_from_dict.assert_called_once() + mock_save.assert_not_called() + class TestEventGeoMethods: """Test cases for geo-related methods.""" diff --git a/backend/tests/apps/slack/apps_test.py b/backend/tests/apps/slack/apps_test.py new file mode 100644 index 0000000000..fc000ee540 --- /dev/null +++ b/backend/tests/apps/slack/apps_test.py @@ -0,0 +1,106 @@ +"""Tests for apps.slack.apps module.""" + +import importlib +import logging +import sys +from unittest.mock import MagicMock, patch + +from django.test import override_settings + + +class TestSlackAppHandlers: + """Tests for error_handler and log_events registered on SlackConfig.app.""" + + @staticmethod + def _reload_with_mock_app(): + """Reload apps.slack.apps with a mocked App to capture registered handlers.""" + mock_app = MagicMock() + handlers = {} + + def capture_error(fn): + handlers["error"] = fn + return fn + + def capture_use(fn): + handlers["use"] = fn + return fn + + mock_app.error = capture_error + mock_app.use = capture_use + original_module = sys.modules.get("apps.slack.apps") + with ( + override_settings(SLACK_BOT_TOKEN="xoxb-test", SLACK_SIGNING_SECRET="test-secret"), # noqa: S106 + patch("slack_bolt.App", return_value=mock_app), + ): + import apps.slack.apps as module # noqa: PLC0415 + + importlib.reload(module) + + return handlers, module, original_module + + @staticmethod + def _restore_module(original_module): + """Restore apps.slack.apps to its original state.""" + if original_module is not None: + sys.modules["apps.slack.apps"] = original_module + else: + sys.modules.pop("apps.slack.apps", None) + + def test_error_handler_logs_exception(self): + """Test error_handler logs the error with body context.""" + handlers, module, orig = self._reload_with_mock_app() + try: + assert "error" in handlers + error_handler = handlers["error"] + + test_error = Exception("Test error") + test_body = {"event": "test"} + + with patch.object(module, "logger") as mock_logger: + error_handler(test_error, test_body) + + mock_logger.exception.assert_called_once_with(test_error, extra={"body": test_body}) + finally: + self._restore_module(orig) + + def test_log_events_creates_event_and_calls_next(self): + """Test log_events creates Event and calls next().""" + handlers, _, orig = self._reload_with_mock_app() + try: + assert "use" in handlers + log_events = handlers["use"] + + mock_client = MagicMock() + mock_context = {"team_id": "T123"} + mock_logger = MagicMock(spec=logging.Logger) + mock_payload = {"type": "event_callback"} + mock_next = MagicMock() + + with patch("apps.slack.models.event.Event.create") as mock_create: + log_events(mock_client, mock_context, mock_logger, mock_payload, mock_next) + + mock_create.assert_called_once_with(mock_context, mock_payload) + mock_next.assert_called_once() + finally: + self._restore_module(orig) + + def test_log_events_handles_exception(self): + """Test log_events catches exceptions from Event.create.""" + handlers, _, orig = self._reload_with_mock_app() + try: + assert "use" in handlers + log_events = handlers["use"] + + mock_client = MagicMock() + mock_context = {"team_id": "T123"} + mock_logger = MagicMock(spec=logging.Logger) + mock_payload = {"type": "event_callback"} + mock_next = MagicMock() + + with patch("apps.slack.models.event.Event.create", side_effect=Exception("DB error")): + log_events(mock_client, mock_context, mock_logger, mock_payload, mock_next) + + mock_logger.exception.assert_called_once_with("Could not log Slack event") + mock_next.assert_called_once() + finally: + self._restore_module(orig) diff --git a/backend/tests/apps/slack/commands/command_test.py b/backend/tests/apps/slack/commands/command_test.py index 8740ab9727..232e4e0ecb 100644 --- a/backend/tests/apps/slack/commands/command_test.py +++ b/backend/tests/apps/slack/commands/command_test.py @@ -38,6 +38,16 @@ def test_configure_commands_returns_early_when_app_is_none(self, mock_slack_conf result = CommandBase.configure_commands() assert result is None + @patch("apps.slack.commands.command.SlackConfig") + def test_configure_commands_registers_commands(self, mock_slack_config): + """Tests that configure_commands iterates and registers all subclasses.""" + mock_slack_config.app = MagicMock() + mock_command_class = MagicMock() + + with patch.object(CommandBase, "get_commands", return_value=[mock_command_class]): + CommandBase.configure_commands() + mock_command_class.return_value.register.assert_called_once() + def test_command_name_property(self, command_instance): """Tests that the command_name is derived correctly from the class name.""" assert command_instance.command_name == "/command" diff --git a/backend/tests/apps/slack/commands/users_test.py b/backend/tests/apps/slack/commands/users_test.py index 66468501c0..f9259ce526 100644 --- a/backend/tests/apps/slack/commands/users_test.py +++ b/backend/tests/apps/slack/commands/users_test.py @@ -1,88 +1,92 @@ from unittest.mock import MagicMock, patch import pytest -from django.conf import settings from apps.slack.commands.users import Users from apps.slack.common.presentation import EntityPresentation -@pytest.fixture -def mock_command(): - return {"user_id": "U123456", "text": ""} +class TestUsersHandler: + @pytest.fixture + def mock_command(self): + return {"user_id": "U123456", "text": ""} + + @pytest.fixture + def mock_client(self): + client = MagicMock() + client.conversations_open.return_value = {"channel": {"id": "C123456"}} + return client + + @pytest.mark.parametrize( + ("commands_enabled", "search_query", "expected_calls"), + [ + (True, "", 1), + (True, "test user", 1), + (False, "", 0), + (False, "test user", 0), + ], + ) + @patch("apps.slack.commands.users.get_blocks") + def test_users_handler( + self, + mock_get_blocks, + mock_client, + mock_command, + settings, + commands_enabled, + search_query, + expected_calls, + ): + settings.SLACK_COMMANDS_ENABLED = commands_enabled + mock_command["text"] = search_query + mock_get_blocks.return_value = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Users"}, + } + ] + + ack = MagicMock() + Users().handler(ack=ack, command=mock_command, client=mock_client) + + ack.assert_called_once() + + ack.assert_called_once() + assert mock_client.chat_postMessage.call_count == expected_calls + + if commands_enabled: + mock_client.conversations_open.assert_called_once_with(users="U123456") + mock_get_blocks.assert_called_once_with( + search_query=search_query, + limit=10, + presentation=EntityPresentation( + include_feedback=True, + include_metadata=True, + include_pagination=False, + include_timestamps=True, + name_truncation=80, + summary_truncation=300, + ), + ) + blocks = mock_client.chat_postMessage.call_args[1]["blocks"] + assert blocks == mock_get_blocks.return_value + assert mock_client.chat_postMessage.call_args[1]["channel"] == "C123456" + + @patch("apps.slack.commands.users.get_blocks") + def test_users_handler_with_blocks(self, mock_get_blocks, mock_client, mock_command, settings): + settings.SLACK_COMMANDS_ENABLED = True + mock_get_blocks.return_value = [ + {"type": "section", "text": {"type": "mrkdwn", "text": "User: Test"}}, + {"type": "divider"}, + ] + + ack = MagicMock() + Users().handler(ack=ack, command=mock_command, client=mock_client) + + ack.assert_called_once() - -@pytest.fixture -def mock_client(): - client = MagicMock() - client.conversations_open.return_value = {"channel": {"id": "C123456"}} - return client - - -@pytest.mark.parametrize( - ("commands_enabled", "search_query", "expected_calls"), - [ - (True, "", 1), - (True, "test user", 1), - (False, "", 0), - (False, "test user", 0), - ], -) -@patch("apps.slack.commands.users.get_blocks") -def test_users_handler( - mock_get_blocks, mock_client, mock_command, commands_enabled, search_query, expected_calls -): - settings.SLACK_COMMANDS_ENABLED = commands_enabled - mock_command["text"] = search_query - mock_get_blocks.return_value = [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "Users"}, - } - ] - - ack = MagicMock() - Users().handler(ack=ack, command=mock_command, client=mock_client) - - ack.assert_called_once() - - ack.assert_called_once() - assert mock_client.chat_postMessage.call_count == expected_calls - - if commands_enabled: mock_client.conversations_open.assert_called_once_with(users="U123456") - mock_get_blocks.assert_called_once_with( - search_query=search_query, - limit=10, - presentation=EntityPresentation( - include_feedback=True, - include_metadata=True, - include_pagination=False, - include_timestamps=True, - name_truncation=80, - summary_truncation=300, - ), - ) blocks = mock_client.chat_postMessage.call_args[1]["blocks"] - assert blocks == mock_get_blocks.return_value - assert mock_client.chat_postMessage.call_args[1]["channel"] == "C123456" - - -@patch("apps.slack.commands.users.get_blocks") -def test_users_handler_with_blocks(mock_get_blocks, mock_client, mock_command): - settings.SLACK_COMMANDS_ENABLED = True - mock_get_blocks.return_value = [ - {"type": "section", "text": {"type": "mrkdwn", "text": "User: Test"}}, - {"type": "divider"}, - ] - - ack = MagicMock() - Users().handler(ack=ack, command=mock_command, client=mock_client) - - ack.assert_called_once() - - mock_client.conversations_open.assert_called_once_with(users="U123456") - blocks = mock_client.chat_postMessage.call_args[1]["blocks"] - assert len(blocks) == len(mock_get_blocks.return_value) - assert blocks[0]["text"]["text"] == "User: Test" - assert blocks[1]["type"] == "divider" + assert len(blocks) == len(mock_get_blocks.return_value) + assert blocks[0]["text"]["text"] == "User: Test" + assert blocks[1]["type"] == "divider" diff --git a/backend/tests/apps/slack/common/handlers/chapters_test.py b/backend/tests/apps/slack/common/handlers/chapters_test.py index 8bdabf5c33..9db01700b0 100644 --- a/backend/tests/apps/slack/common/handlers/chapters_test.py +++ b/backend/tests/apps/slack/common/handlers/chapters_test.py @@ -153,3 +153,25 @@ def test_get_blocks_no_results_no_search_query(self, setup_mocks, mock_empty_cha assert len(blocks) == 1 assert "No chapters found" in blocks[0]["text"]["text"] + + def test_get_blocks_pagination_single_page(self, setup_mocks): + """Test that no pagination buttons are added when there is only one page.""" + mock_data = { + "hits": [ + { + "idx_name": "Chapter", + "idx_country": "US", + "idx_suggested_location": "NY", + "idx_leaders": [], + "idx_summary": "Summary", + "idx_url": "https://example.com", + } + ], + "nbPages": 1, + } + setup_mocks["get_chapters"].return_value = mock_data + presentation = EntityPresentation(include_feedback=False, include_pagination=True) + + blocks = get_blocks(page=1, presentation=presentation) + + assert not any(block.get("type") == "actions" for block in blocks) diff --git a/backend/tests/apps/slack/common/handlers/committees_test.py b/backend/tests/apps/slack/common/handlers/committees_test.py index fb742a2705..4a4ded6a86 100644 --- a/backend/tests/apps/slack/common/handlers/committees_test.py +++ b/backend/tests/apps/slack/common/handlers/committees_test.py @@ -184,3 +184,23 @@ def test_get_blocks_no_results_no_search_query(self, setup_mocks, mock_empty_com assert len(blocks) == 1 assert "No committees found" in blocks[0]["text"]["text"] + + def test_get_blocks_pagination_single_page(self, setup_mocks): + """Test that no pagination buttons are added when there is only one page.""" + mock_data = { + "hits": [ + { + "idx_name": "Committee", + "idx_leaders": [], + "idx_summary": "Summary", + "idx_url": "https://example.com", + } + ], + "nbPages": 1, + } + setup_mocks["get_committees"].return_value = mock_data + presentation = EntityPresentation(include_feedback=False, include_pagination=True) + + blocks = get_blocks(page=1, presentation=presentation) + + assert not any(block.get("type") == "actions" for block in blocks) diff --git a/backend/tests/apps/slack/common/handlers/projects_test.py b/backend/tests/apps/slack/common/handlers/projects_test.py index 5e928c55cc..890bc9134d 100644 --- a/backend/tests/apps/slack/common/handlers/projects_test.py +++ b/backend/tests/apps/slack/common/handlers/projects_test.py @@ -234,3 +234,27 @@ def test_get_blocks_no_results_no_search_query(self, setup_mocks, mock_empty_pro assert len(blocks) == 1 assert "No projects found" in blocks[0]["text"]["text"] + + def test_get_blocks_pagination_single_page(self, setup_mocks): + """Test that no pagination buttons are added when there is only one page.""" + mock_data = { + "hits": [ + { + "idx_name": "Project", + "idx_summary": "Summary", + "idx_url": "https://example.com", + "idx_updated_at": "1704067200", + "idx_contributors_count": 0, + "idx_forks_count": 0, + "idx_stars_count": 0, + "idx_leaders": [], + } + ], + "nbPages": 1, + } + setup_mocks["get_projects"].return_value = mock_data + presentation = EntityPresentation(include_feedback=False, include_pagination=True) + + blocks = get_blocks(page=1, presentation=presentation) + + assert not any(block.get("type") == "actions" for block in blocks) diff --git a/backend/tests/apps/slack/common/handlers/users_test.py b/backend/tests/apps/slack/common/handlers/users_test.py index 7897c7af14..da2bff4cd3 100644 --- a/backend/tests/apps/slack/common/handlers/users_test.py +++ b/backend/tests/apps/slack/common/handlers/users_test.py @@ -187,3 +187,28 @@ def test_get_blocks_no_results_no_search_query(self, mocker): assert len(blocks) == 1 assert "No users found" in blocks[0]["text"]["text"] + + def test_get_blocks_pagination_single_page(self, mocker): + """Test that no pagination buttons are added when there is only one page.""" + mock_data = { + "hits": [ + { + "idx_name": "User", + "idx_login": "testuser", + "idx_url": "https://github.com/testuser", + "idx_bio": "Bio", + "idx_location": "", + "idx_company": "", + "idx_followers_count": 0, + "idx_following_count": 0, + "idx_public_repositories_count": 0, + } + ], + "nbPages": 1, + } + mocker.patch("apps.github.index.search.user.get_users", return_value=mock_data) + presentation = EntityPresentation(include_feedback=False, include_pagination=True) + + blocks = get_blocks(page=1, presentation=presentation) + + assert not any(block.get("type") == "actions" for block in blocks) diff --git a/backend/tests/apps/slack/events/event_test.py b/backend/tests/apps/slack/events/event_test.py index 1945987913..b89e8d5aca 100644 --- a/backend/tests/apps/slack/events/event_test.py +++ b/backend/tests/apps/slack/events/event_test.py @@ -4,7 +4,7 @@ import pytest from slack_sdk.errors import SlackApiError -from apps.slack.blocks import DIVIDER +from apps.slack.blocks import DIVIDER, SECTION_BREAK from apps.slack.events.event import EventBase @@ -257,3 +257,13 @@ def test_render_blocks_with_text_section(self, mocker, event_instance): assert len(result) > 0 assert result[0]["type"] == "section" + + def test_render_blocks_skips_empty_sections(self, event_instance): + """Tests that render_blocks skips empty sections from consecutive SECTION_BREAKs.""" + mock_template = MagicMock() + mock_template.render.return_value = f"Text before{SECTION_BREAK}{SECTION_BREAK}Text after" + + result = event_instance.render_blocks(template=mock_template, context={}) + + assert len(result) == 2 + assert all(block["type"] == "section" for block in result) diff --git a/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py index 9c31dca6cd..625dad0fae 100644 --- a/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py +++ b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py @@ -127,6 +127,14 @@ def test_find_fuzzy_matches_skips_empty_conversation_name(self): assert len(matches) == 1 assert matches[0][0].name == "project-test" + def test_find_fuzzy_matches_below_threshold(self): + """Test that conversations with score below threshold are not matched.""" + cmd = Command() + mock_conv = type("Conversation", (), {"name": "completely-different-channel"})() + + matches = cmd.find_fuzzy_matches("OWASP Juice Shop", [mock_conv], threshold=95) + assert not matches + def test_handle_skips_entity_without_name(self, mocker): """Test that entities with empty name are skipped.""" mock_committee = mocker.Mock(spec=Committee, id=1) 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 ecb70e0cf9..1998145de3 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 @@ -747,3 +747,304 @@ def test_create_message_bot_failure(self, mocker): mock_msg_model.update_data.assert_called_with( data=msg_data, conversation=mocker.ANY, author=None, parent_message=None, save=False ) + + +class TestSyncUserMessagesMissingBranches: + """Tests for remaining uncovered branches in _sync_user_messages.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_sync_message_no_channel_id(self, mocker): + """Test message without channel ID is skipped.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-token") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_ws_instance = MagicMock() + mock_ws_instance.name = "Test" + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.__iter__.return_value = [mock_ws_instance] + mock_workspace.objects.all.return_value = mock_qs + + mock_conversation_cls = mocker.patch(f"{self.target_module}.Conversation") + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + mock_client.search_messages.side_effect = [ + { + "ok": True, + "messages": { + "matches": [ + {"ts": "1704110400", "channel": {}}, + ] + }, + }, + {"ok": True, "messages": {"matches": []}}, + ] + + command = Command() + command.stdout = MagicMock() + + command._sync_user_messages("U123", "2024-01-01", "2024-01-02", 0.1, 3) + + mock_conversation_cls.objects.get_or_create.assert_not_called() + + def test_sync_message_create_returns_none(self, mocker): + """Test when _create_message returns None.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-token") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_ws_instance = MagicMock() + mock_ws_instance.name = "Test" + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.__iter__.return_value = [mock_ws_instance] + mock_workspace.objects.all.return_value = mock_qs + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + mock_conversation_cls = mocker.patch(f"{self.target_module}.Conversation") + mock_conversation_cls.objects.get_or_create.return_value = (MagicMock(), True) + + mock_client.search_messages.side_effect = [ + { + "ok": True, + "messages": { + "matches": [ + {"ts": "1704110400", "channel": {"id": "C1", "name": "general"}}, + ] + }, + }, + {"ok": True, "messages": {"matches": []}}, + ] + + command = Command() + command.stdout = MagicMock() + mocker.patch.object(command, "_create_message", return_value=None) + + command._sync_user_messages("U123", "2024-01-01", "2024-01-02", 0.1, 3) + command.stdout.write.assert_called() + + def test_sync_pagination_continues(self, mocker): + """Test pagination continues to next page.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-token") + mocker.patch(f"{self.target_module}.time.sleep") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_ws_instance = MagicMock() + mock_ws_instance.name = "Test" + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.__iter__.return_value = [mock_ws_instance] + mock_workspace.objects.all.return_value = mock_qs + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + mock_conversation_cls = mocker.patch(f"{self.target_module}.Conversation") + mock_conversation_cls.objects.get_or_create.return_value = (MagicMock(), True) + mock_msg_model = mocker.patch(f"{self.target_module}.Message") + mock_client.search_messages.side_effect = [ + { + "ok": True, + "messages": { + "matches": [ + {"ts": "1704110400", "channel": {"id": "C1", "name": "general"}}, + ] + }, + }, + {"ok": True, "messages": {"matches": []}}, + ] + + command = Command() + command.stdout = MagicMock() + mocker.patch.object(command, "_create_message", return_value=MagicMock()) + + command._sync_user_messages("U123", "2024-01-01", "2024-01-02", 0.1, 3) + assert mock_client.search_messages.call_count == 2 + mock_msg_model.bulk_save.assert_called_once() + + def test_sync_generic_api_error(self, mocker): + """Test non-ratelimited SlackApiError.""" + mocker.patch(f"{self.target_module}.os.environ.get", return_value="xoxp-token") + + mock_workspace = mocker.patch(f"{self.target_module}.Workspace") + mock_ws_instance = MagicMock() + mock_ws_instance.name = "Test" + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.__iter__.return_value = [mock_ws_instance] + mock_workspace.objects.all.return_value = mock_qs + + mock_webclient = mocker.patch(f"{self.target_module}.WebClient") + mock_client = MagicMock() + mock_webclient.return_value = mock_client + + error = SlackApiError( + response={"ok": False, "error": "fatal_error"}, + message="Fatal error", + ) + error.response = {"error": "fatal_error", "ok": False} + mock_client.search_messages.side_effect = error + + command = Command() + command.stdout = MagicMock() + + command._sync_user_messages("U123", None, None, 0.1, 3) + command.stdout.write.assert_called() + + +class TestFetchConversationError: + """Tests for _fetch_conversation error handling.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_fetch_conversation_api_error(self, mocker): + """Test _fetch_conversation catches SlackApiError.""" + mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + mock_conversation = MagicMock() + mock_conversation.name = "test-channel" + + error = SlackApiError( + response={"ok": False, "error": "channel_not_found"}, + message="channel not found", + ) + error.response = {"error": "channel_not_found", "ok": False} + + mocker.patch.object(Command, "_fetch_messages", side_effect=error) + + command = Command() + command.stdout = MagicMock() + + command._fetch_conversation( + client=mock_client, + conversation=mock_conversation, + batch_size=100, + delay=0.1, + max_retries=3, + ) + command.stdout.write.assert_called() + + +class TestFetchRepliesRateLimit: + """Tests for rate limit handling in _fetch_replies.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_fetch_replies_rate_limit_retry(self, mocker): + """Test rate limit retry in _fetch_replies.""" + mocker.patch(f"{self.target_module}.time.sleep") + mock_message_model = mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + 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 = "https://test.com" + + 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.get = lambda key, default=None: ( + "ratelimited" if key == "error" else default + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + + mock_reply = MagicMock() + + mock_client.conversations_replies.side_effect = [ + rate_limit_error, + { + "ok": True, + "messages": [{"user": "U1", "ts": "123.789"}], + "response_metadata": {"next_cursor": ""}, + }, + ] + + command = Command() + command.stdout = 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() + assert mock_client.conversations_replies.call_count == 2 + + def test_fetch_replies_rate_limit_max_retries(self, mocker): + """Test rate limit max retries exceeded in _fetch_replies.""" + mocker.patch(f"{self.target_module}.time.sleep") + mocker.patch(f"{self.target_module}.Message") + + mock_client = MagicMock() + 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 = "https://test.com" + + 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.get = lambda key, default=None: ( + "ratelimited" if key == "error" else default + ) + rate_limit_error.response.headers = {"Retry-After": "1"} + + mock_client.conversations_replies.side_effect = rate_limit_error + + command = Command() + command.stdout = MagicMock() + + command._fetch_replies(mock_client, mock_message, 1.0, 1) + + command.stdout.write.assert_called() + + +class TestCreateMessageMaxRetriesZero: + """Test for _create_message with max_retries=0.""" + + target_module = "apps.slack.management.commands.slack_sync_messages" + + def test_create_message_max_retries_zero(self, mocker): + """Test _create_message when max_retries=0 skips while loop.""" + mock_member = mocker.patch(f"{self.target_module}.Member") + mock_message_model = mocker.patch(f"{self.target_module}.Message") + + mock_member.DoesNotExist = Exception + mock_member.objects.get.side_effect = mock_member.DoesNotExist + + 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, 0) + mock_message_model.update_data.assert_called_once_with( + data=message_data, + conversation=mock_conversation, + author=None, + parent_message=None, + save=False, + ) + mock_client.users_info.assert_not_called() diff --git a/backend/tests/apps/slack/utils_test.py b/backend/tests/apps/slack/utils_test.py index 21a637e46f..386157190f 100644 --- a/backend/tests/apps/slack/utils_test.py +++ b/backend/tests/apps/slack/utils_test.py @@ -54,322 +54,339 @@ """ -@pytest.mark.parametrize( - ("input_text", "expected_output"), - [ - ( - "Check out .", - "Check out current sponsors list (https://owasp.org/supporters/list).", - ), - ( - "Visit for more details.", - "Visit Example (https://example.com) for more details.", - ), - ( - "This is a *bold* text with a .", - "This is a bold text with a link (https://example.com).", - ), - ( - "No links here, just plain text.", - "No links here, just plain text.", - ), - ( - "Multiple links: and .", - "Multiple links: First (https://example.com) and Second (https://example.org).", - ), - ], -) -def test_process_mrkdwn(input_text, expected_output): - """Test the _process_mrkdwn function.""" - assert strip_markdown(input_text) == expected_output - - -@pytest.mark.parametrize( - ("input_text", "expected_output"), - [ - ("Check [link](https://example.com)", "Check "), - ("", ""), - (None, None), - ], -) -def test_format_links_for_slack(input_text, expected_output): - """Test format_links_for_slack with various inputs including empty text.""" - assert format_links_for_slack(input_text) == expected_output - - -@pytest.mark.parametrize( - ("input_blocks", "expected_output"), - [ - ([{"type": "section", "text": {"type": "mrkdwn", "text": "Hello world"}}], "Hello world"), - ( - [ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "This is a context block :smile: with a link here ", - } - ], - } - ], - "This is a context block :smile: with a link here link (https://example.com)", - ), - ([{"type": "divider"}], "---"), - ( - [ - {"type": "section", "text": {"type": "mrkdwn", "text": "*Bold* text"}}, - {"type": "divider"}, - {"type": "context", "elements": [{"type": "mrkdwn", "text": "Context text"}]}, - ], - "Bold text\n---\nContext text", - ), - ( - [ - { - "type": "actions", - "elements": [ - {"type": "button", "text": {"text": "Click me", "type": "plain_text"}} - ], - } - ], - "Click me", - ), - ( - [ - { - "type": "actions", - "elements": [ - {"type": "button", "text": {"text": "Button 1", "type": "plain_text"}}, - {"type": "button", "text": {"text": "Button 2", "type": "plain_text"}}, - {"type": "overflow", "options": []}, - ], - } - ], - "Button 1\nButton 2", - ), - ( - [ - { - "type": "header", - "text": {"type": "plain_text", "text": "Header text"}, - } - ], - "Header text", - ), - ( - [ - { - "type": "header", - "text": {"type": "mrkdwn", "text": "Markdown header"}, - } - ], - "", - ), - ( - [ - { - "type": "header", - } - ], - "", - ), - ( - [ - { - "type": "image", - "image_url": "https://example.com/image.jpg", - "alt_text": "Example", - } - ], - "Image: https://example.com/image.jpg", - ), - ( - [ - { - "type": "section", - "fields": [ - {"type": "mrkdwn", "text": "Field 1"}, - {"type": "mrkdwn", "text": "Field 2"}, - ], - } - ], - "Field 1\nField 2", - ), - ], -) -def test_blocks_to_text(input_blocks, expected_output): - """Test the blocks_to_text function.""" - assert get_text(input_blocks) == expected_output - - -@pytest.mark.parametrize( - ("input_content", "expected_output"), - [ - ("", "<script>alert('XSS')</script>"), - ("Hello, World!", "Hello, World!"), - ("Bold & Italic", "<b>Bold</b> & <i>Italic</i>"), - ], -) -def test_escape(input_content, expected_output): - """Test the escape function.""" - assert escape(input_content) == expected_output - - -def test_get_gsoc_projects_(monkeypatch): - """Test getting GSoC projects with mocked data.""" - mock_get_projects = Mock() - mock_get_projects.return_value = {"hits": MOCK_GSOC_PROJECTS["2023"]} - - monkeypatch.setattr("apps.owasp.index.search.project.get_projects", mock_get_projects) - - result = get_gsoc_projects("2023") - length = 2 - assert len(result) == length - assert result[0]["idx_name"] == "Project1_2023" - assert result[1]["idx_url"] == "https://example.com/proj2" - - mock_get_projects.assert_called_once_with( - attributes=["idx_name", "idx_url"], - query="gsoc2023", - searchable_attributes=[ - "idx_custom_tags", - "idx_languages", - "idx_tags", - "idx_topics", +class TestStripMarkdown: + @pytest.mark.parametrize( + ("input_text", "expected_output"), + [ + ( + "Check out .", + "Check out current sponsors list (https://owasp.org/supporters/list).", + ), + ( + "Visit for more details.", + "Visit Example (https://example.com) for more details.", + ), + ( + "This is a *bold* text with a .", + "This is a bold text with a link (https://example.com).", + ), + ( + "No links here, just plain text.", + "No links here, just plain text.", + ), + ( + "Multiple links: and .", + "Multiple links: First (https://example.com) and Second (https://example.org).", + ), ], ) - - result2 = get_gsoc_projects("2023") - assert mock_get_projects.call_count == 1 - assert result == result2 - - -def test_get_news_data(monkeypatch): - """Test getting news data with mocked response.""" - mock_response = Mock() - mock_response.content = MOCK_HTML_CONTENT.encode() - - mock_get = Mock(return_value=mock_response) - - monkeypatch.setattr("requests.get", mock_get) - get_news_data.cache_clear() - - result = get_news_data() - length = 3 - assert len(result) == length - assert result[0]["title"] == "News Title 1" - assert result[0]["author"] == "Author 1" - assert result[0]["url"] == urljoin(OWASP_NEWS_URL, "/news1") - - length = 2 - result_limited = get_news_data(limit=2) - assert len(result_limited) == length - - mock_get.assert_called_with(OWASP_NEWS_URL, timeout=30) - result2 = get_news_data() - assert mock_get.call_count == length - assert result == result2 - - -def test_get_news_data_with_missing_anchor(monkeypatch): - """Test getting news data when h2 tags don't have anchors.""" - mock_html = """ - - -

Title without anchor

-

Author 1

-

Title with anchor

-

Author 2

- - - """ - mock_response = Mock() - mock_response.content = mock_html.encode() - mock_get = Mock(return_value=mock_response) - - monkeypatch.setattr("requests.get", mock_get) - get_news_data.cache_clear() - - result = get_news_data() - - assert len(result) == 1 - assert result[0]["title"] == "Title with anchor" - - -def test_get_staff_data(monkeypatch): - """Test getting staff data with mocked response.""" - mock_response = Mock() - mock_response.text = MOCK_STAFF_YAML - mock_get = Mock(return_value=mock_response) - monkeypatch.setattr("requests.get", mock_get) - get_staff_data.cache_clear() - - length = 3 - result = get_staff_data() - assert len(result) == length - assert result[0]["name"] == "Alice Smith" - assert result[1]["name"] == "Bob Wilson" - assert result[2]["name"] == "John Doe" - - mock_get.assert_called_once_with( - "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/staff.yml", timeout=30 + def test_process_mrkdwn(self, input_text, expected_output): + """Test the _process_mrkdwn function.""" + assert strip_markdown(input_text) == expected_output + + +class TestFormatLinksForSlack: + @pytest.mark.parametrize( + ("input_text", "expected_output"), + [ + ("Check [link](https://example.com)", "Check "), + ("", ""), + (None, None), + ], ) + def test_format_links_for_slack(self, input_text, expected_output): + """Test format_links_for_slack with various inputs including empty text.""" + assert format_links_for_slack(input_text) == expected_output + + +class TestGetText: + @pytest.mark.parametrize( + ("input_blocks", "expected_output"), + [ + ( + [{"type": "section", "text": {"type": "mrkdwn", "text": "Hello world"}}], + "Hello world", + ), + ( + [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "This is a context block :smile: with a link here ", + } + ], + } + ], + "This is a context block :smile: with a link here link (https://example.com)", + ), + ([{"type": "divider"}], "---"), + ( + [ + {"type": "section", "text": {"type": "mrkdwn", "text": "*Bold* text"}}, + {"type": "divider"}, + {"type": "context", "elements": [{"type": "mrkdwn", "text": "Context text"}]}, + ], + "Bold text\n---\nContext text", + ), + ( + [ + { + "type": "actions", + "elements": [ + {"type": "button", "text": {"text": "Click me", "type": "plain_text"}} + ], + } + ], + "Click me", + ), + ( + [ + { + "type": "actions", + "elements": [ + {"type": "button", "text": {"text": "Button 1", "type": "plain_text"}}, + {"type": "button", "text": {"text": "Button 2", "type": "plain_text"}}, + {"type": "overflow", "options": []}, + ], + } + ], + "Button 1\nButton 2", + ), + ( + [ + { + "type": "header", + "text": {"type": "plain_text", "text": "Header text"}, + } + ], + "Header text", + ), + ( + [ + { + "type": "header", + "text": {"type": "mrkdwn", "text": "Markdown header"}, + } + ], + "", + ), + ( + [ + { + "type": "header", + } + ], + "", + ), + ( + [ + { + "type": "image", + "image_url": "https://example.com/image.jpg", + "alt_text": "Example", + } + ], + "Image: https://example.com/image.jpg", + ), + ( + [ + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "Field 1"}, + {"type": "mrkdwn", "text": "Field 2"}, + ], + } + ], + "Field 1\nField 2", + ), + ( + [{"type": "unknown_block_type", "data": "something"}], + "", + ), + ], + ) + def test_blocks_to_text(self, input_blocks, expected_output): + """Test the blocks_to_text function.""" + assert get_text(input_blocks) == expected_output + + +class TestEscape: + @pytest.mark.parametrize( + ("input_content", "expected_output"), + [ + ("", "<script>alert('XSS')</script>"), + ("Hello, World!", "Hello, World!"), + ( + "Bold & Italic", + "<b>Bold</b> & <i>Italic</i>", + ), + ], + ) + def test_escape(self, input_content, expected_output): + """Test the escape function.""" + assert escape(input_content) == expected_output + + +class TestGetGsocProjects: + def test_get_gsoc_projects(self, monkeypatch): + """Test getting GSoC projects with mocked data.""" + mock_get_projects = Mock() + mock_get_projects.return_value = {"hits": MOCK_GSOC_PROJECTS["2023"]} + + monkeypatch.setattr("apps.owasp.index.search.project.get_projects", mock_get_projects) + get_gsoc_projects.cache_clear() + + result = get_gsoc_projects("2023") + length = 2 + assert len(result) == length + assert result[0]["idx_name"] == "Project1_2023" + assert result[1]["idx_url"] == "https://example.com/proj2" + + mock_get_projects.assert_called_once_with( + attributes=["idx_name", "idx_url"], + query="gsoc2023", + searchable_attributes=[ + "idx_custom_tags", + "idx_languages", + "idx_tags", + "idx_topics", + ], + ) + + result2 = get_gsoc_projects("2023") + assert mock_get_projects.call_count == 1 + assert result == result2 + + +class TestGetNewsData: + def test_get_news_data(self, monkeypatch): + """Test getting news data with mocked response.""" + mock_response = Mock() + mock_response.content = MOCK_HTML_CONTENT.encode() + + mock_get = Mock(return_value=mock_response) + + monkeypatch.setattr("requests.get", mock_get) + get_news_data.cache_clear() + + result = get_news_data() + length = 3 + assert len(result) == length + assert result[0]["title"] == "News Title 1" + assert result[0]["author"] == "Author 1" + assert result[0]["url"] == urljoin(OWASP_NEWS_URL, "/news1") + + length = 2 + result_limited = get_news_data(limit=2) + assert len(result_limited) == length + + mock_get.assert_called_with(OWASP_NEWS_URL, timeout=30) + result2 = get_news_data() + assert mock_get.call_count == length + assert result == result2 + + def test_get_news_data_with_missing_anchor(self, monkeypatch): + """Test getting news data when h2 tags don't have anchors.""" + mock_html = """ + + +

Title without anchor

+

Author 1

+

Title with anchor

+

Author 2

+ + + """ + mock_response = Mock() + mock_response.content = mock_html.encode() + mock_get = Mock(return_value=mock_response) + + monkeypatch.setattr("requests.get", mock_get) + get_news_data.cache_clear() + + result = get_news_data() + + assert len(result) == 1 + assert result[0]["title"] == "Title with anchor" + + +class TestGetStaffData: + def test_get_staff_data(self, monkeypatch): + """Test getting staff data with mocked response.""" + mock_response = Mock() + mock_response.text = MOCK_STAFF_YAML + mock_get = Mock(return_value=mock_response) + monkeypatch.setattr("requests.get", mock_get) + get_staff_data.cache_clear() + + length = 3 + result = get_staff_data() + assert len(result) == length + assert result[0]["name"] == "Alice Smith" + assert result[1]["name"] == "Bob Wilson" + assert result[2]["name"] == "John Doe" + + mock_get.assert_called_once_with( + "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/staff.yml", + timeout=30, + ) + + def test_get_staff_data_request_exception(self, monkeypatch): + """Test get_staff_data handles RequestException gracefully.""" + mock_get = Mock(side_effect=RequestException("Network error")) + monkeypatch.setattr("requests.get", mock_get) + get_staff_data.cache_clear() + + result = get_staff_data() + assert result is None -def test_get_staff_data_request_exception(monkeypatch): - """Test get_staff_data handles RequestException gracefully.""" - mock_get = Mock(side_effect=RequestException("Network error")) - monkeypatch.setattr("requests.get", mock_get) - get_staff_data.cache_clear() - - result = get_staff_data() - assert result is None - - -def test_get_sponsors_data(): - """Test get_sponsors_data returns sponsors queryset.""" - mock_sponsor = Mock() - mock_queryset = Mock() - mock_queryset.__getitem__ = Mock(return_value=[mock_sponsor]) - - with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: - mock_objects.all.return_value = mock_queryset - - result = get_sponsors_data(limit=5) - mock_objects.all.assert_called_once() - assert result is not None +class TestGetSponsorsData: + def test_get_sponsors_data(self): + """Test get_sponsors_data returns sponsors queryset.""" + mock_sponsor = Mock() + mock_queryset = Mock() + mock_queryset.__getitem__ = Mock(return_value=[mock_sponsor]) + with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: + mock_objects.all.return_value = mock_queryset -def test_get_sponsors_data_exception(): - """Test get_sponsors_data handles exceptions gracefully.""" - with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: - mock_objects.all.side_effect = Exception("Database error") + result = get_sponsors_data(limit=5) + mock_objects.all.assert_called_once() + assert result is not None - result = get_sponsors_data() - assert result is None + def test_get_sponsors_data_exception(self): + """Test get_sponsors_data handles exceptions gracefully.""" + with patch("apps.owasp.models.sponsor.Sponsor.objects") as mock_objects: + mock_objects.all.side_effect = Exception("Database error") + result = get_sponsors_data() + assert result is None -def test_get_posts_data(): - """Test get_posts_data returns posts queryset.""" - mock_post = Mock() - mock_queryset = Mock() - mock_queryset.__getitem__ = Mock(return_value=[mock_post]) - with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: - mock_recent.return_value = mock_queryset - get_posts_data.cache_clear() +class TestGetPostsData: + def test_get_posts_data(self): + """Test get_posts_data returns posts queryset.""" + mock_post = Mock() + mock_queryset = Mock() + mock_queryset.__getitem__ = Mock(return_value=[mock_post]) - result = get_posts_data(limit=3) - mock_recent.assert_called_once() - assert result is not None + with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: + mock_recent.return_value = mock_queryset + get_posts_data.cache_clear() + result = get_posts_data(limit=3) + mock_recent.assert_called_once() + assert result is not None -def test_get_posts_data_exception(): - """Test get_posts_data handles exceptions gracefully.""" - with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: - mock_recent.side_effect = Exception("Database error") - get_posts_data.cache_clear() + def test_get_posts_data_exception(self): + """Test get_posts_data handles exceptions gracefully.""" + with patch("apps.owasp.models.post.Post.recent_posts") as mock_recent: + mock_recent.side_effect = Exception("Database error") + get_posts_data.cache_clear() - result = get_posts_data() - assert result is None + result = get_posts_data() + assert result is None