From 90a68fb8397688a2973b6c3d4e59568157a7259b Mon Sep 17 00:00:00 2001 From: anurag2787 Date: Wed, 1 Oct 2025 23:16:40 +0530 Subject: [PATCH 1/2] Sync OWASP Board of Directors Members Data --- .../commands/owasp_sync_board_members.py | 178 +++++++ .../commands/owasp_sync_board_members_test.py | 467 ++++++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 backend/apps/owasp/management/commands/owasp_sync_board_members.py create mode 100644 backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py diff --git a/backend/apps/owasp/management/commands/owasp_sync_board_members.py b/backend/apps/owasp/management/commands/owasp_sync_board_members.py new file mode 100644 index 0000000000..19f2b57b31 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_sync_board_members.py @@ -0,0 +1,178 @@ +"""OWASP Board of Directors members sync command.""" + +import logging +from typing import Any, Dict, List, Optional + +import requests +import yaml +from thefuzz import fuzz + +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from django.db import models, transaction + +from apps.github.models.user import User +from apps.owasp.models.board_of_directors import BoardOfDirectors +from apps.owasp.models.entity_member import EntityMember + +logger = logging.getLogger(__name__) + +BOARD_HISTORY_URL = "https://raw.githubusercontent.com/OWASP/www-board/master/_data/board-history.yml" + + +class Command(BaseCommand): + """Command to sync OWASP Board of Directors members.""" + + help = "Sync OWASP Board of Directors members from www-board repository" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--year", + type=int, + help="Specific year to sync board members for", + required=True, + ) + + def handle(self, *args: Any, **options: Any) -> None: + """Handle command execution.""" + year = options["year"] + + self.stdout.write("Fetching OWASP Board of Directors data...") + try: + response = requests.get(BOARD_HISTORY_URL) + response.raise_for_status() + board_data = yaml.safe_load(response.text) + except (requests.RequestException, yaml.YAMLError) as e: + self.stderr.write( + self.style.ERROR(f"Failed to fetch or parse board history: {str(e)}") + ) + return + + board_members = self._extract_board_members_for_year(board_data, year) + if not board_members: + self.stderr.write( + self.style.ERROR(f"No board members found for year {year}") + ) + return + + self.stdout.write(f"Processing {len(board_members)} board members for year {year}...") + + board, created = BoardOfDirectors.objects.get_or_create(year=year) + if created: + self.stdout.write(f"Created new BoardOfDirectors for year {year}") + + entity_type = ContentType.objects.get_for_model(BoardOfDirectors) + processed_count = 0 + + with transaction.atomic(): + for member_data in board_members: + self._create_or_update_member( + member_data, board, entity_type + ) + processed_count += 1 + if processed_count % 10 == 0: # Show progress every 10 members + self.stdout.write(f"Processed {processed_count} of {len(board_members)} members") + + self.stdout.write( + self.style.SUCCESS(f"Successfully synced {processed_count} board members for year {year}") + ) + + def _extract_board_members_for_year( + self, board_data: List[Dict[str, Any]], year: int + ) -> List[Dict[str, str]]: + """Extract board members for a specific year from board history data.""" + # Find the entry for the specified year + year_entry = next( + (entry for entry in board_data if entry.get("year") == year), + None + ) + + if not year_entry: + return [] + + members = year_entry.get("members", []) + return members + + def _create_or_update_member( + self, + member_data: Dict[str, str], + board: BoardOfDirectors, + entity_type: ContentType, + ) -> None: + """Create or update an EntityMember for a board member.""" + member_name = member_data.get("name", "").strip() + if not member_name: + logger.warning("Skipping member with empty name") + return + + data = { + "entity_id": board.id, + "entity_type": entity_type, + "member_name": member_name, + "role": EntityMember.Role.MEMBER, + "is_active": True, + } + + matched_user = self._fuzzy_match_github_user(member_name) + if matched_user: + data["member"] = matched_user + + try: + entity_member = EntityMember.update_data(data, save=True) + logger.info("Processed board member: %s", member_name) + except Exception as e: + logger.error("Failed to process board member %s: %s", member_name, str(e)) + + def _fuzzy_match_github_user(self, member_name: str) -> User | None: + """Attempt to fuzzy match a board member name with a GitHub user.""" + try: + if not member_name: + return None + + exact_match = User.objects.filter(name__iexact=member_name).first() + if exact_match: + return exact_match + + first_name = member_name.split()[0].lower() + first_letter = first_name[0] if first_name else None + + if not first_letter: + return None + + candidate_users = User.objects.filter( + models.Q(name__istartswith=first_letter) | + models.Q(name__icontains=f" {first_name} ") | + models.Q(name__istartswith=first_name + " ") | + models.Q(name__iendswith=" " + first_name) + ).exclude(name="") + + if candidate_users.count() > 100: + candidate_users = candidate_users.filter( + models.Q(name__istartswith=first_name + " ") | + models.Q(name__icontains=f" {first_name} ") + ) + + SIMILARITY_THRESHOLD = 80 + potential_matches = [] + + for user in candidate_users: + similarity = fuzz.ratio(member_name.lower(), user.name.lower()) + if similarity >= SIMILARITY_THRESHOLD: + potential_matches.append((user, similarity)) + + if potential_matches: + best_match = max(potential_matches, key=lambda x: x[1]) + logger.info( + "Found fuzzy match for %s: %s (similarity: %d%%)", + member_name, + best_match[0].name, + best_match[1] + ) + return best_match[0] + + return None + + except Exception as e: + logger.warning("Failed to match GitHub user for %s: %s", member_name, str(e)) + return None diff --git a/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py b/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py new file mode 100644 index 0000000000..fae5ca680b --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py @@ -0,0 +1,467 @@ +"""Tests for OWASP Board of Directors sync command.""" + +import yaml +from unittest import mock + +import pytest +import requests + +from django.contrib.contenttypes.models import ContentType + +from apps.github.models.user import User +from apps.owasp.management.commands.owasp_sync_board_members import Command +from apps.owasp.models.board_of_directors import BoardOfDirectors +from apps.owasp.models.entity_member import EntityMember + + +class TestOwaspSyncBoardMembersCommand: + """Test cases for OWASP Board of Directors sync command.""" + + BOARD_HISTORY_URL = "https://raw.githubusercontent.com/OWASP/www-board/master/_data/board-history.yml" + + @pytest.fixture + def command(self): + """Return Command instance.""" + return Command() + + @pytest.fixture + def mock_board_data(self): + """Return mock board history data.""" + return [ + { + "year": 2023, + "members": [ + {"name": "John Doe"}, + {"name": "Jane Smith"}, + {"name": "Bob Johnson"}, + ], + }, + { + "year": 2024, + "members": [ + {"name": "Alice Brown"}, + {"name": "Charlie Davis"}, + ], + }, + ] + + @pytest.fixture + def mock_github_user(self): + """Return mock GitHub user.""" + user = mock.Mock(spec=User) + user.name = "John Doe" + user.id = 1 + user.login = "johndoe" + return user + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + @mock.patch("apps.owasp.models.board_of_directors.BoardOfDirectors.objects.get_or_create") + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("django.contrib.contenttypes.models.ContentType.objects.get_for_model") + @mock.patch("django.db.transaction.atomic") + def test_handle_successful_sync( + self, + mock_atomic, + mock_get_content_type, + mock_update_data, + mock_get_or_create, + mock_requests_get, + command, + mock_board_data, + ): + """Test successful board members sync for a specific year.""" + # Setup mocks + mock_response = mock.Mock() + mock_response.text = yaml.dump(mock_board_data) + mock_response.raise_for_status = mock.Mock() + mock_requests_get.return_value = mock_response + + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + mock_get_or_create.return_value = (mock_board, False) + + mock_content_type = mock.Mock(spec=ContentType) + mock_get_content_type.return_value = mock_content_type + + mock_entity_member = mock.Mock(spec=EntityMember) + mock_update_data.return_value = mock_entity_member + + # Mock the atomic context manager + mock_atomic.return_value.__enter__ = mock.Mock() + mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) + + # Execute command + command.handle(year=2023) + + # Assertions + mock_requests_get.assert_called_once_with(self.BOARD_HISTORY_URL) + mock_get_or_create.assert_called_once_with(year=2023) + mock_get_content_type.assert_called_once_with(BoardOfDirectors) + + assert mock_update_data.call_count == 3 + + # Verify update_data called with correct arguments + expected_calls = [ + mock.call( + { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "John Doe", + "role": EntityMember.Role.MEMBER, + "is_active": True, + }, + save=True, + ), + mock.call( + { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "Jane Smith", + "role": EntityMember.Role.MEMBER, + "is_active": True, + }, + save=True, + ), + mock.call( + { + "entity_id": 1, + "entity_type": mock_content_type, + "member_name": "Bob Johnson", + "role": EntityMember.Role.MEMBER, + "is_active": True, + }, + save=True, + ), + ] + mock_update_data.assert_has_calls(expected_calls, any_order=True) + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + def test_handle_requests_exception(self, mock_requests_get, command): + """Test handling of requests exception.""" + mock_requests_get.side_effect = requests.RequestException("Connection error") + + with mock.patch.object(command.stderr, "write") as mock_stderr: + command.handle(year=2023) + mock_stderr.assert_called_once() + error_message = mock_stderr.call_args[0][0] + assert "Failed to fetch or parse board history" in error_message + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + def test_handle_yaml_parse_error(self, mock_requests_get, command): + """Test handling of YAML parse error.""" + mock_response = mock.Mock() + mock_response.text = "invalid: yaml: content: [" + mock_response.raise_for_status = mock.Mock() + mock_requests_get.return_value = mock_response + + with mock.patch.object(command.stderr, "write") as mock_stderr: + command.handle(year=2023) + mock_stderr.assert_called_once() + error_message = mock_stderr.call_args[0][0] + assert "Failed to fetch or parse board history" in error_message + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + def test_handle_no_members_for_year( + self, mock_requests_get, command, mock_board_data + ): + """Test handling when no board members found for specified year.""" + mock_response = mock.Mock() + mock_response.text = yaml.dump(mock_board_data) + mock_response.raise_for_status = mock.Mock() + mock_requests_get.return_value = mock_response + + with mock.patch.object(command.stderr, "write") as mock_stderr: + command.handle(year=2025) # Year not in mock data + mock_stderr.assert_called_once() + error_message = mock_stderr.call_args[0][0] + assert "No board members found for year 2025" in error_message + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + @mock.patch("apps.owasp.models.board_of_directors.BoardOfDirectors.objects.get_or_create") + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("django.contrib.contenttypes.models.ContentType.objects.get_for_model") + @mock.patch("django.db.transaction.atomic") + def test_handle_creates_new_board( + self, + mock_atomic, + mock_get_content_type, + mock_update_data, + mock_get_or_create, + mock_requests_get, + command, + mock_board_data, + ): + """Test that new BoardOfDirectors is created when needed.""" + mock_response = mock.Mock() + mock_response.text = yaml.dump(mock_board_data) + mock_response.raise_for_status = mock.Mock() + mock_requests_get.return_value = mock_response + + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + # created=True indicates new board was created + mock_get_or_create.return_value = (mock_board, True) + + mock_content_type = mock.Mock(spec=ContentType) + mock_get_content_type.return_value = mock_content_type + + mock_entity_member = mock.Mock(spec=EntityMember) + mock_update_data.return_value = mock_entity_member + + # Mock the atomic context manager + mock_atomic.return_value.__enter__ = mock.Mock() + mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) + + with mock.patch.object(command.stdout, "write") as mock_stdout: + command.handle(year=2023) + # Should show message about creating new board + stdout_calls = [str(call) for call in mock_stdout.call_args_list] + created_message_found = any("Created new BoardOfDirectors for year 2023" in call for call in stdout_calls) + assert created_message_found + + mock_get_or_create.assert_called_once_with(year=2023) + + def test_extract_board_members_for_year(self, command, mock_board_data): + """Test extraction of board members for specific year.""" + members = command._extract_board_members_for_year(mock_board_data, 2023) + + assert len(members) == 3 + assert members[0]["name"] == "John Doe" + assert members[1]["name"] == "Jane Smith" + assert members[2]["name"] == "Bob Johnson" + + def test_extract_board_members_for_nonexistent_year(self, command, mock_board_data): + """Test extraction for year that doesn't exist.""" + members = command._extract_board_members_for_year(mock_board_data, 2025) + + assert members == [] + + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.Command._fuzzy_match_github_user") + def test_create_or_update_member_with_github_match( + self, mock_fuzzy_match, mock_update_data, command, mock_github_user + ): + """Test creating member with GitHub user match.""" + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + mock_content_type = mock.Mock(spec=ContentType) + + mock_fuzzy_match.return_value = mock_github_user + mock_update_data.return_value = mock.Mock(spec=EntityMember) + + member_data = {"name": "John Doe"} + + command._create_or_update_member(member_data, mock_board, mock_content_type) + + mock_fuzzy_match.assert_called_once_with("John Doe") + mock_update_data.assert_called_once() + + # Verify the data passed to update_data includes the matched user + call_args = mock_update_data.call_args + assert call_args[0][0]["member"] == mock_github_user + assert call_args[0][0]["member_name"] == "John Doe" + + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.Command._fuzzy_match_github_user") + def test_create_or_update_member_without_github_match( + self, mock_fuzzy_match, mock_update_data, command + ): + """Test creating member without GitHub user match.""" + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + mock_content_type = mock.Mock(spec=ContentType) + + mock_fuzzy_match.return_value = None + mock_update_data.return_value = mock.Mock(spec=EntityMember) + + member_data = {"name": "John Doe"} + + command._create_or_update_member(member_data, mock_board, mock_content_type) + + mock_fuzzy_match.assert_called_once_with("John Doe") + mock_update_data.assert_called_once() + + # Verify the data passed to update_data does not include member field + call_args = mock_update_data.call_args + assert "member" not in call_args[0][0] + + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + def test_create_or_update_member_with_empty_name(self, mock_update_data, command): + """Test that member with empty name is skipped.""" + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_content_type = mock.Mock(spec=ContentType) + + # Test with empty name + command._create_or_update_member({"name": ""}, mock_board, mock_content_type) + mock_update_data.assert_not_called() + + # Test with whitespace only name + command._create_or_update_member({"name": " "}, mock_board, mock_content_type) + mock_update_data.assert_not_called() + + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.Command._fuzzy_match_github_user") + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.logger") + def test_create_or_update_member_update_exception( + self, mock_logger, mock_fuzzy_match, mock_update_data, command + ): + """Test handling of exception during member update.""" + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + mock_content_type = mock.Mock(spec=ContentType) + + mock_fuzzy_match.return_value = None + mock_update_data.side_effect = Exception("Database error") + + member_data = {"name": "John Doe"} + + command._create_or_update_member(member_data, mock_board, mock_content_type) + + mock_update_data.assert_called_once() + mock_logger.error.assert_called_once_with( + "Failed to process board member %s: %s", "John Doe", "Database error" + ) + + @mock.patch("apps.github.models.user.User.objects.filter") + def test_fuzzy_match_github_user_exact_match(self, mock_filter, command, mock_github_user): + """Test fuzzy matching with exact name match.""" + mock_queryset = mock.Mock() + mock_queryset.first.return_value = mock_github_user + mock_filter.return_value = mock_queryset + + result = command._fuzzy_match_github_user("John Doe") + + assert result == mock_github_user + mock_filter.assert_called_with(name__iexact="John Doe") + + @mock.patch("apps.github.models.user.User.objects.filter") + def test_fuzzy_match_github_user_no_match(self, mock_filter, command): + """Test fuzzy matching with no match found.""" + # Mock exact match returns None + mock_exact_queryset = mock.Mock() + mock_exact_queryset.first.return_value = None + + # Mock candidate users query returns empty + mock_candidate_queryset = mock.Mock() + mock_candidate_queryset.count.return_value = 0 + mock_candidate_queryset.exclude.return_value = mock_candidate_queryset + + mock_filter.side_effect = [mock_exact_queryset, mock_candidate_queryset] + + result = command._fuzzy_match_github_user("Unknown Person") + + assert result is None + + def test_fuzzy_match_github_user_empty_name(self, command): + """Test fuzzy matching with empty name.""" + result = command._fuzzy_match_github_user("") + assert result is None + + result = command._fuzzy_match_github_user(None) + assert result is None + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.fuzz") + @mock.patch("apps.github.models.user.User.objects.filter") + def test_fuzzy_match_github_user_with_similarity(self, mock_filter, mock_fuzz, command): + """Test fuzzy matching using similarity threshold.""" + # Mock exact match returns None + mock_exact_queryset = mock.Mock() + mock_exact_queryset.first.return_value = None + + # Mock candidate users + user1 = mock.Mock(spec=User) + user1.name = "Jon Doe" # Similar but not exact + user2 = mock.Mock(spec=User) + user2.name = "John Doe Jr" # Very similar + + mock_candidate_queryset = mock.Mock() + mock_candidate_queryset.count.return_value = 2 + mock_candidate_queryset.exclude.return_value = mock_candidate_queryset + mock_candidate_queryset.__iter__ = mock.Mock(return_value=iter([user1, user2])) + + mock_filter.side_effect = [mock_exact_queryset, mock_candidate_queryset] + + # Mock fuzz.ratio to return high similarity for user2 + mock_fuzz.ratio.side_effect = [75, 85] # user1: 75%, user2: 85% + + result = command._fuzzy_match_github_user("John Doe") + + # Should return user2 as it has higher similarity + assert result == user2 + assert mock_fuzz.ratio.call_count == 2 + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.logger") + @mock.patch("apps.github.models.user.User.objects.filter") + def test_fuzzy_match_github_user_exception(self, mock_filter, mock_logger, command): + """Test fuzzy matching handles exceptions gracefully.""" + mock_filter.side_effect = Exception("Database error") + + result = command._fuzzy_match_github_user("John Doe") + + assert result is None + mock_logger.warning.assert_called_once_with( + "Failed to match GitHub user for %s: %s", "John Doe", mock.ANY + ) + + @pytest.mark.parametrize("year", [2020, 2023, 2024]) + def test_add_arguments(self, command, year): + """Test command arguments parsing.""" + parser = mock.Mock() + command.add_arguments(parser) + + parser.add_argument.assert_called_once_with( + "--year", + type=int, + help="Specific year to sync board members for", + required=True, + ) + + @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") + @mock.patch("apps.owasp.models.board_of_directors.BoardOfDirectors.objects.get_or_create") + @mock.patch("apps.owasp.models.entity_member.EntityMember.update_data") + @mock.patch("django.contrib.contenttypes.models.ContentType.objects.get_for_model") + @mock.patch("django.db.transaction.atomic") + def test_progress_reporting( + self, + mock_atomic, + mock_get_content_type, + mock_update_data, + mock_get_or_create, + mock_requests_get, + command, + ): + """Test progress reporting during sync.""" + # Create board data with more than 10 members to test progress reporting + board_data = [ + { + "year": 2023, + "members": [{"name": f"Member {i}"} for i in range(15)], + } + ] + + mock_response = mock.Mock() + mock_response.text = yaml.dump(board_data) + mock_response.raise_for_status = mock.Mock() + mock_requests_get.return_value = mock_response + + mock_board = mock.Mock(spec=BoardOfDirectors) + mock_board.id = 1 + mock_get_or_create.return_value = (mock_board, True) + + mock_content_type = mock.Mock(spec=ContentType) + mock_get_content_type.return_value = mock_content_type + + mock_entity_member = mock.Mock(spec=EntityMember) + mock_update_data.return_value = mock_entity_member + + # Mock the atomic context manager + mock_atomic.return_value.__enter__ = mock.Mock() + mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) + + with mock.patch.object(command.stdout, "write") as mock_stdout: + command.handle(year=2023) + + # Should show progress at 10th member + stdout_calls = [str(call) for call in mock_stdout.call_args_list] + progress_message_found = any("Processed 10 of 15 members" in call for call in stdout_calls) + assert progress_message_found \ No newline at end of file From 9c9e161eae9eb8b440b8f02ff1fdab944d6f7726 Mon Sep 17 00:00:00 2001 From: anurag2787 Date: Thu, 2 Oct 2025 00:29:45 +0530 Subject: [PATCH 2/2] update code --- .../commands/owasp_sync_board_members.py | 12 ++++- .../commands/owasp_sync_board_members_test.py | 50 ++++++++----------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/backend/apps/owasp/management/commands/owasp_sync_board_members.py b/backend/apps/owasp/management/commands/owasp_sync_board_members.py index 19f2b57b31..92a4238053 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_board_members.py +++ b/backend/apps/owasp/management/commands/owasp_sync_board_members.py @@ -40,7 +40,7 @@ def handle(self, *args: Any, **options: Any) -> None: self.stdout.write("Fetching OWASP Board of Directors data...") try: - response = requests.get(BOARD_HISTORY_URL) + response = requests.get(BOARD_HISTORY_URL, timeout=15) response.raise_for_status() board_data = yaml.safe_load(response.text) except (requests.RequestException, yaml.YAMLError) as e: @@ -111,7 +111,6 @@ def _create_or_update_member( "entity_type": entity_type, "member_name": member_name, "role": EntityMember.Role.MEMBER, - "is_active": True, } matched_user = self._fuzzy_match_github_user(member_name) @@ -120,6 +119,15 @@ def _create_or_update_member( try: entity_member = EntityMember.update_data(data, save=True) + updates: list[str] = [] + if not entity_member.is_active: + entity_member.is_active = True + updates.append("is_active") + if matched_user and entity_member.member_id != matched_user.id: + entity_member.member = matched_user + updates.append("member") + if updates: + entity_member.save(update_fields=updates) logger.info("Processed board member: %s", member_name) except Exception as e: logger.error("Failed to process board member %s: %s", member_name, str(e)) diff --git a/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py b/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py index fae5ca680b..a8e6aab54f 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_sync_board_members_test.py @@ -70,7 +70,6 @@ def test_handle_successful_sync( mock_board_data, ): """Test successful board members sync for a specific year.""" - # Setup mocks mock_response = mock.Mock() mock_response.text = yaml.dump(mock_board_data) mock_response.raise_for_status = mock.Mock() @@ -83,24 +82,27 @@ def test_handle_successful_sync( mock_content_type = mock.Mock(spec=ContentType) mock_get_content_type.return_value = mock_content_type - mock_entity_member = mock.Mock(spec=EntityMember) - mock_update_data.return_value = mock_entity_member + mock_entity_members = [] + for i in range(3): + mock_entity_member = mock.Mock(spec=EntityMember) + mock_entity_member.is_active = False + mock_entity_member.member_id = None + mock_entity_member.save = mock.Mock() + mock_entity_members.append(mock_entity_member) + + mock_update_data.side_effect = mock_entity_members - # Mock the atomic context manager mock_atomic.return_value.__enter__ = mock.Mock() mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) - # Execute command command.handle(year=2023) - # Assertions - mock_requests_get.assert_called_once_with(self.BOARD_HISTORY_URL) + mock_requests_get.assert_called_once_with(self.BOARD_HISTORY_URL, timeout=15) mock_get_or_create.assert_called_once_with(year=2023) mock_get_content_type.assert_called_once_with(BoardOfDirectors) assert mock_update_data.call_count == 3 - # Verify update_data called with correct arguments expected_calls = [ mock.call( { @@ -108,7 +110,6 @@ def test_handle_successful_sync( "entity_type": mock_content_type, "member_name": "John Doe", "role": EntityMember.Role.MEMBER, - "is_active": True, }, save=True, ), @@ -118,7 +119,6 @@ def test_handle_successful_sync( "entity_type": mock_content_type, "member_name": "Jane Smith", "role": EntityMember.Role.MEMBER, - "is_active": True, }, save=True, ), @@ -128,12 +128,18 @@ def test_handle_successful_sync( "entity_type": mock_content_type, "member_name": "Bob Johnson", "role": EntityMember.Role.MEMBER, - "is_active": True, }, save=True, ), ] mock_update_data.assert_has_calls(expected_calls, any_order=True) + + for mock_entity_member in mock_entity_members: + mock_entity_member.save.assert_called_once() + call_args = mock_entity_member.save.call_args + args, kwargs = call_args + assert 'update_fields' in kwargs + assert 'is_active' in kwargs['update_fields'] @mock.patch("apps.owasp.management.commands.owasp_sync_board_members.requests.get") def test_handle_requests_exception(self, mock_requests_get, command): @@ -199,7 +205,6 @@ def test_handle_creates_new_board( mock_board = mock.Mock(spec=BoardOfDirectors) mock_board.id = 1 - # created=True indicates new board was created mock_get_or_create.return_value = (mock_board, True) mock_content_type = mock.Mock(spec=ContentType) @@ -208,13 +213,11 @@ def test_handle_creates_new_board( mock_entity_member = mock.Mock(spec=EntityMember) mock_update_data.return_value = mock_entity_member - # Mock the atomic context manager mock_atomic.return_value.__enter__ = mock.Mock() mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) with mock.patch.object(command.stdout, "write") as mock_stdout: command.handle(year=2023) - # Should show message about creating new board stdout_calls = [str(call) for call in mock_stdout.call_args_list] created_message_found = any("Created new BoardOfDirectors for year 2023" in call for call in stdout_calls) assert created_message_found @@ -256,7 +259,6 @@ def test_create_or_update_member_with_github_match( mock_fuzzy_match.assert_called_once_with("John Doe") mock_update_data.assert_called_once() - # Verify the data passed to update_data includes the matched user call_args = mock_update_data.call_args assert call_args[0][0]["member"] == mock_github_user assert call_args[0][0]["member_name"] == "John Doe" @@ -281,7 +283,6 @@ def test_create_or_update_member_without_github_match( mock_fuzzy_match.assert_called_once_with("John Doe") mock_update_data.assert_called_once() - # Verify the data passed to update_data does not include member field call_args = mock_update_data.call_args assert "member" not in call_args[0][0] @@ -291,11 +292,9 @@ def test_create_or_update_member_with_empty_name(self, mock_update_data, command mock_board = mock.Mock(spec=BoardOfDirectors) mock_content_type = mock.Mock(spec=ContentType) - # Test with empty name command._create_or_update_member({"name": ""}, mock_board, mock_content_type) mock_update_data.assert_not_called() - # Test with whitespace only name command._create_or_update_member({"name": " "}, mock_board, mock_content_type) mock_update_data.assert_not_called() @@ -337,11 +336,9 @@ def test_fuzzy_match_github_user_exact_match(self, mock_filter, command, mock_gi @mock.patch("apps.github.models.user.User.objects.filter") def test_fuzzy_match_github_user_no_match(self, mock_filter, command): """Test fuzzy matching with no match found.""" - # Mock exact match returns None mock_exact_queryset = mock.Mock() mock_exact_queryset.first.return_value = None - # Mock candidate users query returns empty mock_candidate_queryset = mock.Mock() mock_candidate_queryset.count.return_value = 0 mock_candidate_queryset.exclude.return_value = mock_candidate_queryset @@ -364,15 +361,13 @@ def test_fuzzy_match_github_user_empty_name(self, command): @mock.patch("apps.github.models.user.User.objects.filter") def test_fuzzy_match_github_user_with_similarity(self, mock_filter, mock_fuzz, command): """Test fuzzy matching using similarity threshold.""" - # Mock exact match returns None mock_exact_queryset = mock.Mock() mock_exact_queryset.first.return_value = None - # Mock candidate users user1 = mock.Mock(spec=User) - user1.name = "Jon Doe" # Similar but not exact + user1.name = "Jon Doe" user2 = mock.Mock(spec=User) - user2.name = "John Doe Jr" # Very similar + user2.name = "John Doe Jr" mock_candidate_queryset = mock.Mock() mock_candidate_queryset.count.return_value = 2 @@ -381,12 +376,10 @@ def test_fuzzy_match_github_user_with_similarity(self, mock_filter, mock_fuzz, c mock_filter.side_effect = [mock_exact_queryset, mock_candidate_queryset] - # Mock fuzz.ratio to return high similarity for user2 - mock_fuzz.ratio.side_effect = [75, 85] # user1: 75%, user2: 85% + mock_fuzz.ratio.side_effect = [75, 85] result = command._fuzzy_match_github_user("John Doe") - # Should return user2 as it has higher similarity assert result == user2 assert mock_fuzz.ratio.call_count == 2 @@ -431,7 +424,6 @@ def test_progress_reporting( command, ): """Test progress reporting during sync.""" - # Create board data with more than 10 members to test progress reporting board_data = [ { "year": 2023, @@ -454,14 +446,12 @@ def test_progress_reporting( mock_entity_member = mock.Mock(spec=EntityMember) mock_update_data.return_value = mock_entity_member - # Mock the atomic context manager mock_atomic.return_value.__enter__ = mock.Mock() mock_atomic.return_value.__exit__ = mock.Mock(return_value=None) with mock.patch.object(command.stdout, "write") as mock_stdout: command.handle(year=2023) - # Should show progress at 10th member stdout_calls = [str(call) for call in mock_stdout.call_args_list] progress_message_found = any("Processed 10 of 15 members" in call for call in stdout_calls) assert progress_message_found \ No newline at end of file