diff --git a/backend/apps/github/models/__init__.py b/backend/apps/github/models/__init__.py index 4d20560f7b..5b4113b60b 100644 --- a/backend/apps/github/models/__init__.py +++ b/backend/apps/github/models/__init__.py @@ -2,6 +2,7 @@ from .comment import Comment from .commit import Commit +from .issue import Issue from .label import Label from .milestone import Milestone from .pull_request import PullRequest diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ae114a3e05..653f3fd7db 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -146,7 +146,6 @@ run.branch = true run.omit = [ "**/admin.py", "**/apps.py", - "**/mentorship/*", # TODO: work in progress "**/migrations/*", "__init__.py", "manage.py", diff --git a/backend/tests/apps/mentorship/api/internal/nodes/api_internal_enum_test.py b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_enum_test.py new file mode 100644 index 0000000000..0c3ffe5eee --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_enum_test.py @@ -0,0 +1,21 @@ +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum, ProgramStatusEnum +from apps.mentorship.models import Program +from apps.mentorship.models.common.experience_level import ExperienceLevel + + +def test_experience_level_enum_values(): + """Test that ExperienceLevelEnum maps correctly to model choices.""" + assert ExperienceLevelEnum.BEGINNER.value == ExperienceLevel.ExperienceLevelChoices.BEGINNER + assert ( + ExperienceLevelEnum.INTERMEDIATE.value + == ExperienceLevel.ExperienceLevelChoices.INTERMEDIATE + ) + assert ExperienceLevelEnum.ADVANCED.value == ExperienceLevel.ExperienceLevelChoices.ADVANCED + assert ExperienceLevelEnum.EXPERT.value == ExperienceLevel.ExperienceLevelChoices.EXPERT + + +def test_program_status_enum_values(): + """Test that ProgramStatusEnum maps correctly to model choices.""" + assert ProgramStatusEnum.DRAFT.value == Program.ProgramStatus.DRAFT + assert ProgramStatusEnum.PUBLISHED.value == Program.ProgramStatus.PUBLISHED + assert ProgramStatusEnum.COMPLETED.value == Program.ProgramStatus.COMPLETED diff --git a/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentee_test.py b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentee_test.py new file mode 100644 index 0000000000..ec08c10045 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentee_test.py @@ -0,0 +1,56 @@ +import pytest + +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum +from apps.mentorship.api.internal.nodes.mentee import MenteeNode + + +@pytest.fixture +def mock_mentee_node(): + """Fixture for a mock MenteeNode instance.""" + return MenteeNode( + id="1", + login="test_mentee", + name="Test Mentee", + avatar_url="https://example.com/avatar.jpg", + bio="A test mentee", + experience_level=ExperienceLevelEnum.BEGINNER, + domains=["python"], + tags=["backend"], + ) + + +def test_mentee_node_fields(mock_mentee_node): + """Test that MenteeNode fields are correctly assigned.""" + assert mock_mentee_node.id == "1" + assert mock_mentee_node.login == "test_mentee" + assert mock_mentee_node.name == "Test Mentee" + assert mock_mentee_node.avatar_url == "https://example.com/avatar.jpg" + assert mock_mentee_node.bio == "A test mentee" + assert mock_mentee_node.experience_level == ExperienceLevelEnum.BEGINNER + assert mock_mentee_node.domains == ["python"] + assert mock_mentee_node.tags == ["backend"] + + +def test_mentee_node_resolve_avatar_url(mock_mentee_node): + """Test the resolve_avatar_url method.""" + assert mock_mentee_node.resolve_avatar_url() == "https://example.com/avatar.jpg" + + +def test_mentee_node_resolve_experience_level(mock_mentee_node): + """Test the resolve_experience_level method.""" + assert mock_mentee_node.resolve_experience_level() == ExperienceLevelEnum.BEGINNER + + +def test_mentee_node_resolve_experience_level_none(): + """Test the resolve_experience_level method when experience_level is None.""" + mentee_node_no_exp = MenteeNode( + id="2", + login="no_exp_mentee", + name="No Experience Mentee", + avatar_url="https://example.com/noexp.jpg", + bio=None, + experience_level=None, # type: ignore[assignment] + domains=None, + tags=None, + ) + assert mentee_node_no_exp.resolve_experience_level() == "beginner" diff --git a/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentor_test.py b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentor_test.py new file mode 100644 index 0000000000..aeca96bc22 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_mentor_test.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock + +import pytest + +from apps.mentorship.api.internal.nodes.mentor import MentorNode + + +@pytest.fixture +def mock_github_user(): + """Fixture for a mock GithubUser.""" + mock = MagicMock() + mock.avatar_url = "https://example.com/mentor_avatar.jpg" + mock.name = "Mentor Name" + mock.login = "mentor_login" + return mock + + +@pytest.fixture +def mock_mentor_node(mock_github_user): + """Fixture for a mock MentorNode instance.""" + mentor_node = MentorNode(id="1") + mentor_node.github_user = mock_github_user + return mentor_node + + +@pytest.fixture +def mock_mentor_node_no_github_user(): + """Fixture for a mock MentorNode instance without a GitHub user.""" + mentor_node = MentorNode(id="2") + mentor_node.github_user = None + return mentor_node + + +def test_mentor_node_id(mock_mentor_node): + """Test that MentorNode id is correctly assigned.""" + assert str(mock_mentor_node.id) == "1" + + +def test_mentor_node_avatar_url(mock_mentor_node): + """Test the avatar_url field resolver.""" + assert mock_mentor_node.avatar_url() == "https://example.com/mentor_avatar.jpg" + + +def test_mentor_node_avatar_url_no_github_user(mock_mentor_node_no_github_user): + """Test avatar_url when no github_user is associated.""" + assert mock_mentor_node_no_github_user.avatar_url() == "" + + +def test_mentor_node_name(mock_mentor_node): + """Test the name field resolver.""" + assert mock_mentor_node.name() == "Mentor Name" + + +def test_mentor_node_name_no_github_user(mock_mentor_node_no_github_user): + """Test name when no github_user is associated.""" + assert mock_mentor_node_no_github_user.name() == "" + + +def test_mentor_node_login(mock_mentor_node): + """Test the login field resolver.""" + assert mock_mentor_node.login() == "mentor_login" + + +def test_mentor_node_login_no_github_user(mock_mentor_node_no_github_user): + """Test login when no github_user is associated.""" + assert mock_mentor_node_no_github_user.login() == "" diff --git a/backend/tests/apps/mentorship/api/internal/nodes/api_internal_module_test.py b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_module_test.py new file mode 100644 index 0000000000..74e953a093 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_module_test.py @@ -0,0 +1,353 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest +import strawberry + +from apps.github.api.internal.nodes.issue import MERGED_PULL_REQUESTS_PREFETCH +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum +from apps.mentorship.api.internal.nodes.module import ( + CreateModuleInput, + ModuleNode, + UpdateModuleInput, +) + + +def _call_module_resolver(instance: object, name: str, *args: object, **kwargs: object) -> object: + """Call a ModuleNode field resolver by name without invoking StrawberryField.__call__.""" + definition = getattr(ModuleNode, "__strawberry_definition__", None) + assert definition is not None + field = next((f for f in definition.fields if f.name == name), None) + assert field is not None + assert field.base_resolver is not None + return field.base_resolver.wrapped_func(instance, *args, **kwargs) + + +class FakeModuleNode: + """Minimal ModuleNode-like object for testing.""" + + def __init__(self): + self.id = strawberry.ID("1") + self.key = "test-module" + self.name = "Test Module" + self.description = "A test mentorship module." + self.domains = ["web", "mobile"] + self.ended_at = datetime(2025, 12, 31, tzinfo=UTC) + self.experience_level = ExperienceLevelEnum.INTERMEDIATE + self.labels = ["django", "react"] + self.program = MagicMock() + self.program.id = strawberry.ID("program-1") + self.program.key = "test-program" + self.project_id = strawberry.ID("project-1") + self.started_at = datetime(2025, 1, 1, tzinfo=UTC) + self.tags = ["backend", "frontend"] + + self.mentors = MagicMock() + self.menteemodule_set = MagicMock() + self.issues = MagicMock() + self.project = MagicMock() + self.project.name = "Test Project" + + def mock_mentors(self): + return _call_module_resolver(self, "mentors") + + def mock_mentees(self): + return _call_module_resolver(self, "mentees") + + def mock_issue_mentees(self, issue_number: int): + return _call_module_resolver(self, "issue_mentees", issue_number=issue_number) + + def mock_project_name(self): + return _call_module_resolver(self, "project_name") + + def mock_issues(self, limit: int = 20, offset: int = 0, label: str | None = None): + return _call_module_resolver(self, "issues", limit=limit, offset=offset, label=label) + + def mock_issues_count(self, label: str | None = None): + return _call_module_resolver(self, "issues_count", label=label) + + def mock_available_labels(self): + return _call_module_resolver(self, "available_labels") + + def mock_issue_by_number(self, number: int): + return _call_module_resolver(self, "issue_by_number", number=number) + + def mock_interested_users(self, issue_number: int): + return _call_module_resolver(self, "interested_users", issue_number=issue_number) + + def mock_task_deadline(self, issue_number: int): + return _call_module_resolver(self, "task_deadline", issue_number=issue_number) + + def mock_task_assigned_at(self, issue_number: int): + return _call_module_resolver(self, "task_assigned_at", issue_number=issue_number) + + +@pytest.fixture +def mock_module_node(): + """Fixture for a mock ModuleNode instance.""" + m = FakeModuleNode() + + m.mentors.all.return_value = [MagicMock(), MagicMock()] + m.menteemodule_set.select_related.return_value.filter.return_value.values_list.return_value = [ + "github_user_id_1", + "github_user_id_2", + ] + m.issues.filter.return_value.values_list.return_value = ["issue_id_1"] + mocked_query_prefetch = m.issues.select_related.return_value.prefetch_related.return_value + mocked_query_prefetch.filter.return_value.order_by.return_value = [MagicMock()] + m.issues.count.return_value = 5 + mocked_query_prefetch = m.issues.select_related.return_value.prefetch_related.return_value + mocked_query_prefetch.filter.return_value.first.return_value = MagicMock() + + return m + + +def test_module_node_fields(mock_module_node): + """Test that ModuleNode fields are correctly assigned.""" + assert mock_module_node.id == "1" + assert mock_module_node.key == "test-module" + assert mock_module_node.name == "Test Module" + assert mock_module_node.description == "A test mentorship module." + assert mock_module_node.domains == ["web", "mobile"] + assert mock_module_node.ended_at == datetime(2025, 12, 31, tzinfo=UTC) + assert mock_module_node.experience_level == ExperienceLevelEnum.INTERMEDIATE + assert mock_module_node.labels == ["django", "react"] + assert str(mock_module_node.program.id) == "program-1" + assert mock_module_node.program.key == "test-program" + assert str(mock_module_node.project_id) == "project-1" + assert mock_module_node.started_at == datetime(2025, 1, 1, tzinfo=UTC) + assert mock_module_node.tags == ["backend", "frontend"] + + +def test_module_node_mentors(mock_module_node): + """Test the mentors resolver.""" + mentors = mock_module_node.mock_mentors() + assert len(mentors) == 2 + mock_module_node.mentors.all.assert_called_once() + + +def test_module_node_mentees(mock_module_node): + """Test the mentees resolver.""" + with patch("apps.github.models.user.User.objects") as mock_user_objects: + mock_user_objects.filter.return_value.order_by.return_value = [MagicMock(), MagicMock()] + + mentees = mock_module_node.mock_mentees() + assert len(mentees) == 2 + mock_module_node.menteemodule_set.select_related.assert_called_once_with( + "mentee__github_user" + ) + mock_user_objects.filter.assert_called_once_with( + id__in=["github_user_id_1", "github_user_id_2"] + ) + mock_user_objects.filter.return_value.order_by.assert_called_once_with("login") + + +def test_module_node_issue_mentees(mock_module_node): + """Test the issue_mentees resolver.""" + with ( + patch("apps.mentorship.models.task.Task.objects") as mock_task_objects, + patch("apps.github.models.user.User.objects") as mock_user_objects, + ): + mock_task_filter_related = ( + mock_task_objects.filter.return_value.select_related.return_value + ) + mock_task_filter_related.values_list.return_value.distinct.return_value = ["assignee_id_1"] + mock_user_objects.filter.return_value.order_by.return_value = [MagicMock()] + + mentees = mock_module_node.mock_issue_mentees(issue_number=123) + assert len(mentees) == 1 + mock_module_node.issues.filter.assert_called_once_with(number=123) + mock_task_objects.filter.assert_called_once_with( + module=mock_module_node, issue_id__in=["issue_id_1"], assignee__isnull=False + ) + mock_user_objects.filter.assert_called_once_with(id__in=["assignee_id_1"]) + mock_user_objects.filter.return_value.order_by.assert_called_once_with("login") + + +def test_module_node_issue_mentees_no_issue(mock_module_node): + """Test issue_mentees when no issue is found.""" + mock_module_node.issues.filter.return_value.values_list.return_value = [] + mentees = mock_module_node.mock_issue_mentees(issue_number=123) + assert mentees == [] + + +def test_module_node_project_name(mock_module_node): + """Test the project_name resolver.""" + assert mock_module_node.mock_project_name() == "Test Project" + + +def test_module_node_project_name_no_project(): + """Test project_name when no project is associated.""" + mock = FakeModuleNode() + mock.project = None + assert mock.mock_project_name() is None + + +def test_module_node_issues_with_label(mock_module_node): + """Test the issues resolver with a label filter.""" + issues_list = mock_module_node.mock_issues(label="bug") + assert len(issues_list) == 1 + mock_module_node_qs_related = mock_module_node.issues.select_related.return_value + mock_module_node_qs_related.prefetch_related.return_value.filter.assert_called_once_with( + labels__name="bug" + ) + + +def test_module_node_issues_count(mock_module_node): + """Test the issues_count resolver.""" + count = mock_module_node.mock_issues_count() + assert count == 5 + mock_module_node.issues.count.assert_called_once() + + +def test_module_node_issues_count_with_label(mock_module_node): + """Test issues_count with a label filter.""" + mock_module_node.issues.filter.return_value.count.return_value = 2 + count = mock_module_node.mock_issues_count(label="feature") + assert count == 2 + mock_module_node.issues.filter.assert_called_once_with(labels__name="feature") + + +def test_module_node_available_labels(mock_module_node): + """Test the available_labels resolver.""" + with patch("apps.github.models.Label.objects") as mock_label_objects: + mock_label_objects.filter.return_value.values_list.return_value.distinct.return_value = [ + "label1", + "label2", + ] + + labels = mock_module_node.mock_available_labels() + assert labels == ["label1", "label2"] + mock_label_objects.filter.assert_called_once_with( + issue__mentorship_modules=mock_module_node + ) + + +def test_module_node_issue_by_number(mock_module_node): + """Test the issue_by_number resolver.""" + issue = mock_module_node.mock_issue_by_number(number=456) + assert issue is not None + mock_module_node.issues.select_related.assert_called_once_with("repository", "author") + mock_module_node.issues.select_related.return_value.prefetch_related.assert_called_once_with( + "assignees", "labels", MERGED_PULL_REQUESTS_PREFETCH + ) + mock_module_node_qs_related = mock_module_node.issues.select_related.return_value + mock_module_node_qs_related.prefetch_related.return_value.filter.assert_called_once_with( + number=456 + ) + mock_node_related = mock_module_node.issues.select_related.return_value + mock_node_related.prefetch_related.return_value.filter.return_value.first.assert_called_once() + + +def test_module_node_interested_users(mock_module_node): + """Test the interested_users resolver.""" + with patch( + "apps.mentorship.models.issue_user_interest.IssueUserInterest.objects" + ) as mock_issue_user_interest_objects: + mock_interest1 = MagicMock() + mock_interest1.user = MagicMock(login="user1") + mock_interest2 = MagicMock() + mock_interest2.user = MagicMock(login="user2") + mock_user_interests = mock_issue_user_interest_objects.select_related.return_value + mock_user_interests.filter.return_value.order_by.return_value = [ + mock_interest1, + mock_interest2, + ] + + users = mock_module_node.mock_interested_users(issue_number=789) + assert len(users) == 2 + assert users[0].login == "user1" + assert users[1].login == "user2" + mock_module_node.issues.filter.assert_called_once_with(number=789) + mock_issue_user_interest_objects.select_related.assert_called_once_with("user") + mock_issue_user_interest_objects.select_related.return_value.filter.assert_called_once_with( + module=mock_module_node, issue_id__in=["issue_id_1"] + ) + + +def test_module_node_interested_users_no_issue(mock_module_node): + """Test interested_users when no issue is found.""" + mock_module_node.issues.filter.return_value.values_list.return_value = [] + users = mock_module_node.mock_interested_users(issue_number=789) + assert users == [] + + +def test_module_node_task_deadline(mock_module_node): + """Test task_deadline method.""" + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_order_by = mock_task_objects.filter.return_value.order_by.return_value + mock_task_order_by.values_list.return_value.first.return_value = datetime( + 2025, 10, 26, tzinfo=UTC + ) + + deadline = mock_module_node.mock_task_deadline(issue_number=101) + assert deadline == datetime(2025, 10, 26, tzinfo=UTC) + mock_task_objects.filter.assert_called_once_with( + module=mock_module_node, issue__number=101, deadline_at__isnull=False + ) + + +def test_module_node_task_deadline_none(mock_module_node): + """Test task_deadline when no deadline is found.""" + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_order_by = mock_task_objects.filter.return_value.order_by + mock_task_order_by.return_value.values_list.return_value.first.return_value = None + + deadline = mock_module_node.mock_task_deadline(issue_number=101) + assert deadline is None + + +def test_module_node_task_assigned_at(mock_module_node): + """Test task_assigned_at method.""" + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_order_by = mock_task_objects.filter.return_value.order_by + mock_task_order_by.return_value.values_list.return_value.first.return_value = datetime( + 2025, 9, 15, tzinfo=UTC + ) + + assigned_at = mock_module_node.mock_task_assigned_at(issue_number=202) + assert assigned_at == datetime(2025, 9, 15, tzinfo=UTC) + mock_task_objects.filter.assert_called_once_with( + module=mock_module_node, issue__number=202, assigned_at__isnull=False + ) + + +def test_module_node_task_assigned_at_none(mock_module_node): + """Test task_assigned_at when no assignment timestamp is found.""" + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_order_by = mock_task_objects.filter.return_value.order_by.return_value + mock_task_order_by.values_list.return_value.first.return_value = None + + assigned_at = mock_module_node.mock_task_assigned_at(issue_number=202) + assert assigned_at is None + + +def test_create_update_input_defaults(): + create_input = CreateModuleInput( + name="Test", + description="Desc", + ended_at=datetime.now(UTC), + experience_level=ExperienceLevelEnum.BEGINNER, + program_key="key", + project_name="Project", + project_id="id", + started_at=datetime.now(UTC), + ) + assert create_input.domains == [] + assert create_input.labels == [] + assert create_input.tags == [] + + update_input = UpdateModuleInput( + key="test-key", + program_key="key", + name="Test", + description="Desc", + ended_at=datetime.now(UTC), + experience_level=ExperienceLevelEnum.BEGINNER, + project_id="id", + project_name="Project", + started_at=datetime.now(UTC), + ) + assert update_input.domains == [] + assert update_input.labels == [] + assert update_input.tags == [] diff --git a/backend/tests/apps/mentorship/api/internal/nodes/api_internal_program_test.py b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_program_test.py new file mode 100644 index 0000000000..35afdc1d65 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/api_internal_program_test.py @@ -0,0 +1,138 @@ +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest +import strawberry + +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum, ProgramStatusEnum +from apps.mentorship.api.internal.nodes.program import ( + CreateProgramInput, + PaginatedPrograms, + ProgramNode, + UpdateProgramInput, + UpdateProgramStatusInput, +) + + +class FakeProgramNode: + def __init__(self): + self.id = strawberry.ID("prog-1") + self.key = "test-program" + self.name = "Test Program" + self.description = "A test mentorship program." + self.domains = ["backend", "frontend"] + self.ended_at = datetime(2026, 6, 30, tzinfo=UTC) + self.experience_levels = [ExperienceLevelEnum.BEGINNER, ExperienceLevelEnum.INTERMEDIATE] + self.mentees_limit = 10 + self.started_at = datetime(2026, 1, 1, tzinfo=UTC) + self.status = ProgramStatusEnum.PUBLISHED + self.user_role = "admin" + self.tags = ["python", "javascript"] + self.admins = MagicMock() + + # the real resolver code should behave similarly: return the manager's .all() + def mock_admins(self): + return ProgramNode.admins(self) + + +@pytest.fixture +def mock_program_node(): + """Fixture returning a FakeProgramNode with a mocked admins manager.""" + node = FakeProgramNode() + + node.admins.order_by.return_value = [ + MagicMock(name="admin1"), + MagicMock(name="admin2"), + ] + + return node + + +def test_program_node_fields(mock_program_node): + """Test that ProgramNode fields are correctly assigned.""" + assert mock_program_node.id == "prog-1" + assert mock_program_node.key == "test-program" + assert mock_program_node.name == "Test Program" + assert mock_program_node.description == "A test mentorship program." + assert mock_program_node.domains == ["backend", "frontend"] + assert mock_program_node.ended_at == datetime(2026, 6, 30, tzinfo=UTC) + assert mock_program_node.experience_levels == [ + ExperienceLevelEnum.BEGINNER, + ExperienceLevelEnum.INTERMEDIATE, + ] + assert mock_program_node.mentees_limit == 10 + assert mock_program_node.started_at == datetime(2026, 1, 1, tzinfo=UTC) + assert mock_program_node.status == ProgramStatusEnum.PUBLISHED + assert mock_program_node.user_role == "admin" + assert mock_program_node.tags == ["python", "javascript"] + + +def test_program_node_admins(mock_program_node): + """Test the admins resolver.""" + admins = mock_program_node.mock_admins() + assert len(admins) == 2 + + +def test_paginated_programs_fields(): + """Test that PaginatedPrograms fields are correctly defined.""" + mock_programs = [MagicMock(spec=ProgramNode), MagicMock(spec=ProgramNode)] + paginated_programs = PaginatedPrograms(current_page=1, programs=mock_programs, total_pages=5) + + assert paginated_programs.current_page == 1 + assert paginated_programs.programs == mock_programs + assert paginated_programs.total_pages == 5 + + +def test_create_program_input_fields(): + """Test that CreateProgramInput fields are correctly defined.""" + assert CreateProgramInput.__annotations__["name"] is str + assert CreateProgramInput.__annotations__["description"] is str + assert CreateProgramInput.__annotations__["domains"] == list[str] + assert CreateProgramInput.__annotations__["ended_at"] is datetime + assert CreateProgramInput.__annotations__["mentees_limit"] is int + assert CreateProgramInput.__annotations__["started_at"] is datetime + assert CreateProgramInput.__annotations__["tags"] == list[str] + + create_input = CreateProgramInput( + name="New Program", + description="Description for new program", + ended_at=datetime.now(UTC), + mentees_limit=5, + started_at=datetime.now(UTC), + ) + assert create_input.domains == [] + assert create_input.tags == [] + + +def test_update_program_input_fields(): + """Test that UpdateProgramInput fields are correctly defined.""" + assert UpdateProgramInput.__annotations__["key"] is str + assert UpdateProgramInput.__annotations__["name"] is str + assert UpdateProgramInput.__annotations__["description"] is str + assert UpdateProgramInput.__annotations__["admin_logins"] == list[str] | None + assert UpdateProgramInput.__annotations__["domains"] == list[str] | None + assert UpdateProgramInput.__annotations__["ended_at"] is datetime + assert UpdateProgramInput.__annotations__["mentees_limit"] is int + assert UpdateProgramInput.__annotations__["started_at"] is datetime + assert UpdateProgramInput.__annotations__["status"] is ProgramStatusEnum + assert UpdateProgramInput.__annotations__["tags"] == list[str] | None + + update_input = UpdateProgramInput( + key="update-program-key", + name="Updated Program", + description="Updated description", + ended_at=datetime.now(UTC), + mentees_limit=12, + started_at=datetime.now(UTC), + status=ProgramStatusEnum.COMPLETED, + ) + assert update_input.domains is None + assert update_input.admin_logins is None + assert update_input.tags is None + + +def test_update_program_status_input_fields(): + """Test that UpdateProgramStatusInput fields are correctly defined.""" + assert UpdateProgramStatusInput.__annotations__["key"] is str + assert UpdateProgramStatusInput.__annotations__["name"] is str + assert UpdateProgramStatusInput.__annotations__["status"] is ProgramStatusEnum diff --git a/backend/tests/apps/mentorship/api/internal/queries/__init__.py b/backend/tests/apps/mentorship/api/internal/queries/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/mentorship/api/internal/queries/api_queries_mentorship_test.py b/backend/tests/apps/mentorship/api/internal/queries/api_queries_mentorship_test.py new file mode 100644 index 0000000000..da7563768c --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/queries/api_queries_mentorship_test.py @@ -0,0 +1,460 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from apps.github.models import User as GithubUser +from apps.mentorship.api.internal.nodes.mentee import MenteeNode +from apps.mentorship.api.internal.queries.mentorship import MentorshipQuery +from apps.mentorship.models import Mentee, Module + + +@pytest.fixture +def api_mentorship_queries() -> MentorshipQuery: + """Pytest fixture to return an instance of the query resolver class.""" + return MentorshipQuery() + + +class TestIsMentor: + """Tests for the is_mentor query.""" + + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.get") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentor.objects.filter") + def test_is_mentor_true( + self, + mock_mentor_filter: MagicMock, + mock_github_user_get: MagicMock, + api_mentorship_queries, + ) -> None: + """Test that is_mentor returns True when the user is a mentor.""" + mock_github_user_get.return_value = MagicMock(spec=GithubUser) + mock_mentor_filter.return_value.exists.return_value = True + + is_mentor = api_mentorship_queries.is_mentor + result = is_mentor(login="testuser") + + assert result is True + mock_github_user_get.assert_called_once_with(login="testuser") + mock_mentor_filter.assert_called_once() + mock_mentor_filter.return_value.exists.assert_called_once() + + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.get") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentor.objects.filter") + def test_is_mentor_false_not_mentor( + self, + mock_mentor_filter: MagicMock, + mock_github_user_get: MagicMock, + api_mentorship_queries, + ) -> None: + """Test that is_mentor returns False when the user is not a mentor.""" + mock_github_user_get.return_value = MagicMock(spec=GithubUser) + mock_mentor_filter.return_value.exists.return_value = False + + is_mentor = api_mentorship_queries.is_mentor + result = is_mentor(login="testuser") + + assert result is False + mock_github_user_get.assert_called_once_with(login="testuser") + mock_mentor_filter.assert_called_once() + mock_mentor_filter.return_value.exists.assert_called_once() + + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.get") + def test_is_mentor_false_no_github_user( + self, mock_github_user_get: MagicMock, api_mentorship_queries + ) -> None: + """Test that is_mentor returns False when the GitHub user does not exist.""" + mock_github_user_get.side_effect = GithubUser.DoesNotExist + + is_mentor = api_mentorship_queries.is_mentor + result = is_mentor(login="non_existent_user") + + assert result is False + mock_github_user_get.assert_called_once_with(login="non_existent_user") + + @pytest.mark.parametrize("login", ["", " ", None]) + def test_is_mentor_false_empty_login(self, login: str | None, api_mentorship_queries) -> None: + """Test that is_mentor returns False for empty or whitespace login.""" + is_mentor = api_mentorship_queries.is_mentor + result = is_mentor(login=login) + + assert result is False + + +class TestGetMenteeDetails: + """Tests for the get_mentee_details query.""" + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_details_success( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + api_mentorship_queries, + ) -> None: + """Test successful retrieval of mentee details.""" + mock_module = MagicMock(spec=Module) + mock_module.id = 1 + mock_module_only.return_value.get.return_value = mock_module + + mock_github_user = MagicMock(spec=GithubUser) + mock_github_user.login = "test_mentee" + mock_github_user.name = "Test_Mentee" + mock_github_user.avatar_url = "url" + mock_github_user.bio = "bio" + + mock_github_user_only.return_value.get.return_value = mock_github_user + + mock_mentee = MagicMock(spec=Mentee) + mock_mentee.id = 2 + mock_mentee.experience_level = "Mid" + mock_mentee.domains = ["Web"] + mock_mentee.tags = ["Python"] + mock_mentee_only.return_value.get.return_value = mock_mentee + + mock_mentee_module_filter.return_value.exists.return_value = True + + get_mentee_details = api_mentorship_queries.get_mentee_details + result = get_mentee_details( + program_key="program", module_key="module", mentee_key="test_mentee" + ) + + assert isinstance(result, MenteeNode) + assert result.login == "test_mentee" + assert result.name == "Test_Mentee" + assert result.experience_level == "Mid" + assert "Python" in result.tags + + mock_module_only.assert_called_once_with("id") + mock_module_only.return_value.get.assert_called_once_with( + key="module", program__key="program" + ) + mock_github_user_only.assert_called_once_with("login", "name", "avatar_url", "bio") + mock_github_user_only.return_value.get.assert_called_once_with(login="test_mentee") + mock_mentee_only.assert_called_once_with("id", "experience_level", "domains", "tags") + mock_mentee_only.return_value.get.assert_called_once_with(github_user=mock_github_user) + mock_mentee_module_filter.assert_called_once_with(mentee=mock_mentee, module=mock_module) + mock_mentee_module_filter.return_value.exists.assert_called_once() + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + def test_get_mentee_details_module_does_not_exist( + self, mock_module_only: MagicMock, api_mentorship_queries + ) -> None: + """Test when the module does not exist.""" + mock_module_only.return_value.get.side_effect = Module.DoesNotExist + + get_mentee_details = api_mentorship_queries.get_mentee_details + result = get_mentee_details( + program_key="program", module_key="nonexistent", mentee_key="test_mentee" + ) + assert result is None + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + def test_get_mentee_details_github_user_does_not_exist( + self, mock_github_user_only: MagicMock, mock_module_only: MagicMock, api_mentorship_queries + ) -> None: + """Test when the GitHub user does not exist.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.side_effect = GithubUser.DoesNotExist + + get_mentee_details = api_mentorship_queries.get_mentee_details + result = get_mentee_details( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + assert result is None + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + def test_get_mentee_details_mentee_does_not_exist( + self, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + api_mentorship_queries, + ) -> None: + """Test when the mentee does not exist.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.return_value = MagicMock( + spec=GithubUser, login="test_mentee" + ) + mock_mentee_only.return_value.get.side_effect = Mentee.DoesNotExist + + get_mentee_details = api_mentorship_queries.get_mentee_details + result = get_mentee_details( + program_key="program", module_key="module", mentee_key="test_mentee" + ) + assert result is None + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_details_not_enrolled( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + api_mentorship_queries, + ) -> None: + """Test when the mentee is not enrolled in the module.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.return_value = MagicMock( + spec=GithubUser, login="test_mentee" + ) + mock_mentee_only.return_value.get.return_value = MagicMock(spec=Mentee, id=2) + mock_mentee_module_filter.return_value.exists.return_value = False + + get_mentee_details = api_mentorship_queries.get_mentee_details + result = get_mentee_details( + program_key="program", module_key="module", mentee_key="test_mentee" + ) + assert result is None + + +class TestGetMenteeModuleIssues: + """Tests for the get_mentee_module_issues query.""" + + @patch("apps.mentorship.api.internal.queries.mentorship.Prefetch") + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_module_issues_success( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + mock_prefetch: MagicMock, + api_mentorship_queries, + ) -> None: + """Test successful retrieval of mentee module issues.""" + mock_prefetch.return_value = MagicMock() + mock_module = MagicMock(spec=Module, id=1) + mock_module_only.return_value.get.return_value = mock_module + + mock_github_user = MagicMock(spec=GithubUser, id=1, login="test_mentee") + mock_github_user_only.return_value.get.return_value = mock_github_user + + mock_mentee = MagicMock(spec=Mentee, id=2) + mock_mentee_only.return_value.get.return_value = mock_mentee + + mock_mentee_module_filter.return_value.exists.return_value = True + + mock_issue1 = MagicMock( + id=1, number=1, title="Issue 1", state="open", created_at="", url="" + ) + mock_issue2 = MagicMock( + id=2, number=2, title="Issue 2", state="closed", created_at="", url="" + ) + + mock_issues_qs = MagicMock() + mock_issues_qs.__getitem__.return_value = [mock_issue1, mock_issue2] + + mock_module_filter = ( + mock_module.issues.filter.return_value.only.return_value.prefetch_related + ) + mock_module_filter.return_value.order_by.return_value = mock_issues_qs + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="test_mentee" + ) + + assert len(result) == 2 + assert result[0].title == "Issue 1" + assert result[1].title == "Issue 2" + + mock_module_only.assert_called_once_with("id") + mock_module_only.return_value.get.assert_called_once_with( + key="module", program__key="program" + ) + mock_github_user_only.return_value.get.assert_called_once_with(login="test_mentee") + mock_mentee_only.assert_called_once_with("id") + mock_mentee_only.return_value.get.assert_called_once_with(github_user=mock_github_user) + mock_mentee_module_filter.assert_called_once_with(mentee=mock_mentee, module=mock_module) + mock_mentee_module_filter.return_value.exists.assert_called_once() + mock_module.issues.filter.assert_called_once_with(assignees=mock_github_user) + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + def test_get_mentee_module_issues_module_does_not_exist( + self, mock_module_only: MagicMock, api_mentorship_queries + ) -> None: + """Test when the module does not exist.""" + mock_module_only.return_value.get.side_effect = Module.DoesNotExist + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", module_key="nonexistent", mentee_key="test_mentee" + ) + assert result == [] + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + def test_get_mentee_module_issues_github_user_does_not_exist( + self, mock_github_user_only: MagicMock, mock_module_only: MagicMock, api_mentorship_queries + ) -> None: + """Test when the GitHub user does not exist.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.side_effect = GithubUser.DoesNotExist + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + assert result == [] + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + def test_get_mentee_module_issues_mentee_does_not_exist( + self, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + api_mentorship_queries, + ) -> None: + """Test when the mentee does not exist.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.return_value = MagicMock( + spec=GithubUser, login="test_mentee" + ) + mock_mentee_only.return_value.get.side_effect = Mentee.DoesNotExist + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + assert result == [] + + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_module_issues_not_enrolled( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + api_mentorship_queries, + ) -> None: + """Test when the mentee is not enrolled in the module.""" + mock_module_only.return_value.get.return_value = MagicMock(spec=Module, id=1) + mock_github_user_only.return_value.get.return_value = MagicMock( + spec=GithubUser, login="test_mentee" + ) + mock_mentee_only.return_value.get.return_value = MagicMock(spec=Mentee, id=2) + mock_mentee_module_filter.return_value.exists.return_value = False + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + assert result == [] + + @patch("apps.mentorship.api.internal.queries.mentorship.Prefetch") + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_module_issues_pagination( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + mock_prefetch: MagicMock, + api_mentorship_queries, + ) -> None: + """Test pagination for mentee module issues.""" + mock_prefetch.return_value = MagicMock() + mock_module = MagicMock(spec=Module, id=1) + mock_module_only.return_value.get.return_value = mock_module + + mock_github_user = MagicMock(spec=GithubUser, id=1, login="test_mentee") + mock_github_user_only.return_value.get.return_value = mock_github_user + + mock_mentee = MagicMock(spec=Mentee, id=2) + mock_mentee_only.return_value.get.return_value = mock_mentee + + mock_mentee_module_filter.return_value.exists.return_value = True + + mock_issue2 = MagicMock( + id=2, number=2, title="Issue 2", state="closed", created_at="", url="" + ) + + mock_issues_qs_slice = [mock_issue2] + + mock_issues_qs = MagicMock() + mock_issues_qs.__getitem__.return_value = mock_issues_qs_slice + + mock_module_filter = ( + mock_module.issues.filter.return_value.only.return_value.prefetch_related + ) + mock_module_filter.return_value.order_by.return_value = mock_issues_qs + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", + module_key="module", + mentee_key="test_mentee", + limit=1, + offset=1, + ) + + assert len(result) == 1 + assert result[0].title == "Issue 2" + + mock_module.issues.filter.assert_called_once_with(assignees=mock_github_user) + + @patch("apps.mentorship.api.internal.queries.mentorship.Prefetch") + @patch("apps.mentorship.api.internal.queries.mentorship.Module.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.GithubUser.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.Mentee.objects.only") + @patch("apps.mentorship.api.internal.queries.mentorship.MenteeModule.objects.filter") + def test_get_mentee_module_issues_pagination_out_of_range( + self, + mock_mentee_module_filter: MagicMock, + mock_mentee_only: MagicMock, + mock_github_user_only: MagicMock, + mock_module_only: MagicMock, + mock_prefetch: MagicMock, + api_mentorship_queries, + ) -> None: + """If offset is beyond available issues, expect an empty list (current behavior).""" + mock_prefetch.return_value = MagicMock() + mock_module = MagicMock(spec=Module, id=1) + mock_module_only.return_value.get.return_value = mock_module + + mock_github_user = MagicMock(spec=GithubUser, id=1, login="test_mentee") + mock_github_user_only.return_value.get.return_value = mock_github_user + + mock_mentee = MagicMock(spec=Mentee, id=2) + mock_mentee_only.return_value.get.return_value = mock_mentee + + mock_mentee_module_filter.return_value.exists.return_value = True + + mock_issues_qs = MagicMock() + mock_issues_qs.__getitem__.return_value = [] + + mock_module_filter = ( + mock_module.issues.filter.return_value.only.return_value.prefetch_related + ) + mock_module_filter.return_value.order_by.return_value = mock_issues_qs + + get_mentee_module_issues = api_mentorship_queries.get_mentee_module_issues + result = get_mentee_module_issues( + program_key="program", + module_key="module", + mentee_key="test_mentee", + limit=10, + offset=1000, + ) + + assert result == [] diff --git a/backend/tests/apps/mentorship/api/internal/queries/api_queries_module_test.py b/backend/tests/apps/mentorship/api/internal/queries/api_queries_module_test.py new file mode 100644 index 0000000000..65ce61adaf --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/queries/api_queries_module_test.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from apps.mentorship.api.internal.queries.module import ModuleQuery +from apps.mentorship.models import Module + + +@pytest.fixture +def api_module_queries() -> ModuleQuery: + """Pytest fixture to return an instance of the query resolver class.""" + return ModuleQuery() + + +class TestModuleQuery: + """Tests for ModuleQuery.""" + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.filter") + def test_get_program_modules_success( + self, mock_module_filter: MagicMock, api_module_queries + ) -> None: + """Test successful retrieval of modules by program key.""" + mock_module = MagicMock(spec=Module) + mock_module_filter_related = mock_module_filter.return_value.select_related.return_value + mock_module_filter_related.prefetch_related.return_value.order_by.return_value = [ + mock_module + ] + result = api_module_queries.get_program_modules(program_key="program1") + + assert result == [mock_module] + mock_module_filter.assert_called_once_with(program__key="program1") + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.filter") + def test_get_program_modules_empty( + self, mock_module_filter: MagicMock, api_module_queries + ) -> None: + """Test retrieval of modules by program key returns empty list if no modules found.""" + mock_module_filter_related = mock_module_filter.return_value.select_related.return_value + mock_module_filter_related.prefetch_related.return_value.order_by.return_value = [] + + result = api_module_queries.get_program_modules(program_key="nonexistent_program") + + assert result == [] + mock_module_filter.assert_called_once_with(program__key="nonexistent_program") + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.filter") + def test_get_project_modules_success( + self, mock_module_filter: MagicMock, api_module_queries + ) -> None: + """Test successful retrieval of modules by project key.""" + mock_module = MagicMock(spec=Module) + mock_module_filter_related = mock_module_filter.return_value.select_related.return_value + mock_module_filter_related.prefetch_related.return_value.order_by.return_value = [ + mock_module + ] + + result = api_module_queries.get_project_modules(project_key="project1") + + assert result == [mock_module] + mock_module_filter.assert_called_once_with(project__key="project1") + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.filter") + def test_get_project_modules_empty( + self, mock_module_filter: MagicMock, api_module_queries + ) -> None: + """Test retrieval of modules by project key returns empty list if no modules found.""" + mock_module_filter_related = mock_module_filter.return_value.select_related.return_value + mock_module_filter_related.prefetch_related.return_value.order_by.return_value = [] + + result = api_module_queries.get_project_modules(project_key="nonexistent_project") + + assert result == [] + mock_module_filter.assert_called_once_with(project__key="nonexistent_project") + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.select_related") + def test_get_module_success( + self, mock_module_select_related: MagicMock, api_module_queries + ) -> None: + """Test successful retrieval of a single module.""" + mock_module = MagicMock(spec=Module) + mock_module_select_related.return_value.prefetch_related.return_value.get.return_value = ( + mock_module + ) + + result = api_module_queries.get_module(module_key="module1", program_key="program1") + + assert result == mock_module + mock_module_select_related.assert_called_once_with("program", "project") + mock_module_select_related.return_value.prefetch_related.return_value.get.assert_called_once_with( + key="module1", program__key="program1" + ) + + @patch("apps.mentorship.api.internal.queries.module.Module.objects.select_related") + def test_get_module_does_not_exist( + self, mock_module_select_related: MagicMock, api_module_queries + ) -> None: + """Test when the module does not exist.""" + mock_module_select_related.return_value.prefetch_related.return_value.get.side_effect = ( + Module.DoesNotExist + ) + + result = api_module_queries.get_module(module_key="nonexistent", program_key="program1") + assert result is None + mock_module_select_related.assert_called_once_with("program", "project") + mock_module_select_related.return_value.prefetch_related.return_value.get.assert_called_once_with( + key="nonexistent", program__key="program1" + ) diff --git a/backend/tests/apps/mentorship/api/internal/queries/api_queries_program_test.py b/backend/tests/apps/mentorship/api/internal/queries/api_queries_program_test.py new file mode 100644 index 0000000000..8da20af8d2 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/queries/api_queries_program_test.py @@ -0,0 +1,232 @@ +from unittest.mock import MagicMock, patch + +import pytest +import strawberry + +from apps.github.models import User as GithubUser +from apps.mentorship.api.internal.nodes.program import PaginatedPrograms +from apps.mentorship.api.internal.queries.program import ProgramQuery +from apps.mentorship.models import Mentor, Program + + +@pytest.fixture +def api_program_queries() -> ProgramQuery: + """Pytest fixture to return an instance of the query resolver class.""" + return ProgramQuery() + + +@pytest.fixture +def mock_info() -> MagicMock: + """Fixture for a mock strawberry.Info object.""" + mock_github_user = MagicMock(spec=GithubUser, id=1, login="testuser") + mock_user = MagicMock() + mock_user.github_user = mock_github_user + mock_request = MagicMock() + mock_request.user = mock_user + mock_info = MagicMock(spec=strawberry.Info) + mock_info.context.request = mock_request + return mock_info + + +class TestGetProgram: + """Tests for the get_program query.""" + + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_get_program_success( + self, mock_program_prefetch_related: MagicMock, mock_info: MagicMock, api_program_queries + ) -> None: + """Test successful retrieval of a program by key.""" + mock_program = MagicMock(spec=Program) + mock_program_prefetch_related.return_value.get.return_value = mock_program + + result = api_program_queries.get_program(program_key="program1") + + assert result == mock_program + mock_program_prefetch_related.assert_called_once_with("admins__github_user") + mock_program_prefetch_related.return_value.get.assert_called_once_with(key="program1") + + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_get_program_does_not_exist( + self, mock_program_prefetch_related: MagicMock, mock_info: MagicMock, api_program_queries + ) -> None: + """Test when the program does not exist.""" + mock_program_prefetch_related.return_value.get.side_effect = Program.DoesNotExist + + result = api_program_queries.get_program(program_key="nonexistent") + + assert result is None + mock_program_prefetch_related.assert_called_once_with("admins__github_user") + mock_program_prefetch_related.return_value.get.assert_called_once_with(key="nonexistent") + + +class TestMyPrograms: + """Tests for the my_programs query.""" + + @patch("apps.mentorship.api.internal.queries.program.Mentor.objects.select_related") + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_my_programs_success( + self, + mock_program_prefetch: MagicMock, + mock_mentor_select: MagicMock, + mock_info: MagicMock, + api_program_queries, + ) -> None: + """Test successful retrieval of user's programs as admin and mentor.""" + mock_mentor = MagicMock(spec=Mentor, id=1) + mock_mentor_select.return_value.get.return_value = mock_mentor + + mock_admin_program = MagicMock(spec=Program, nest_created_at="2023-01-01", id=1) + mock_admin_program.admins.all.return_value = [mock_mentor] + mock_admin_program.modules.return_value.mentors.return_value.github_user.return_value = [] + + mock_mentor_program = MagicMock(spec=Program, nest_created_at="2023-01-02", id=2) + mock_mentor_program.admins.all.return_value = [] + mock_mentor_program.modules.return_value.mentors.return_value = [mock_mentor] + + mock_queryset = MagicMock() + mock_queryset.count.return_value = 2 + mock_queryset.order_by.return_value.__getitem__.return_value = [ + mock_mentor_program, + mock_admin_program, + ] + mock_queryset.filter.return_value = mock_queryset # For search chain + + mock_program_prefetch.return_value.filter.return_value.distinct.return_value = ( + mock_queryset + ) + + result = api_program_queries.my_programs(info=mock_info) + + assert isinstance(result, PaginatedPrograms) + assert len(result.programs) == 2 + assert result.total_pages == 1 + assert result.current_page == 1 + assert result.programs[0].user_role == "mentor" + assert result.programs[1].user_role == "admin" + + mock_mentor_select.return_value.get.assert_called_once() + mock_program_prefetch.assert_called_once_with( + "admins__github_user", "modules__mentors__github_user" + ) + mock_program_prefetch.return_value.filter.assert_called_once() + mock_queryset.order_by.assert_called_once_with("-nest_created_at") + + @patch("apps.mentorship.api.internal.queries.program.Mentor.objects.select_related") + def test_my_programs_mentor_does_not_exist( + self, mock_mentor_select: MagicMock, mock_info: MagicMock, api_program_queries + ) -> None: + """Test when the current user is not a mentor.""" + mock_mentor_select.return_value.get.side_effect = Mentor.DoesNotExist + + result = api_program_queries.my_programs(info=mock_info) + + assert isinstance(result, PaginatedPrograms) + assert result.programs == [] + assert result.total_pages == 0 + assert result.current_page == 1 + mock_mentor_select.return_value.get.assert_called_once() + + @patch("apps.mentorship.api.internal.queries.program.Mentor.objects.select_related") + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_my_programs_no_programs_found( + self, + mock_program_prefetch: MagicMock, + mock_mentor_select: MagicMock, + mock_info: MagicMock, + api_program_queries, + ) -> None: + """Test when no programs are found for the mentor.""" + mock_mentor = MagicMock(spec=Mentor, id=1) + mock_mentor_select.return_value.get.return_value = mock_mentor + + mock_queryset = MagicMock() + mock_queryset.count.return_value = 0 + mock_queryset.order_by.return_value.__getitem__.return_value = [] + mock_queryset.filter.return_value = mock_queryset + + mock_program_prefetch.return_value.filter.return_value.distinct.return_value = ( + mock_queryset + ) + + result = api_program_queries.my_programs(info=mock_info) + + assert isinstance(result, PaginatedPrograms) + assert result.programs == [] + assert result.total_pages == 1 + assert result.current_page == 1 + + @patch("apps.mentorship.api.internal.queries.program.Mentor.objects.select_related") + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_my_programs_with_search( + self, + mock_program_prefetch: MagicMock, + mock_mentor_select: MagicMock, + mock_info: MagicMock, + api_program_queries, + ) -> None: + """Test my_programs with a search query.""" + mock_mentor = MagicMock(spec=Mentor, id=1) + mock_mentor_select.return_value.get.return_value = mock_mentor + + mock_program = MagicMock(spec=Program, nest_created_at="2023-01-01", id=1) + mock_program.admins.all.return_value = [mock_mentor] + mock_program.modules.return_value.mentors.return_value.github_user.return_value = [] + + mock_queryset_filtered = MagicMock() + mock_queryset_filtered.count.return_value = 1 + mock_queryset_filtered.order_by.return_value.__getitem__.return_value = [mock_program] + + mock_queryset_initial = MagicMock() + mock_queryset_initial.filter.return_value.distinct.return_value.filter.return_value = ( + mock_queryset_filtered + ) + mock_program_prefetch.return_value = mock_queryset_initial + + result = api_program_queries.my_programs(info=mock_info, search="test") + + assert len(result.programs) == 1 + mock_program_prefetch_filter = mock_program_prefetch.return_value.filter.return_value + mock_program_prefetch_filter.distinct.return_value.filter.assert_called_once_with( + name__icontains="test" + ) + + @patch("apps.mentorship.api.internal.queries.program.Mentor.objects.select_related") + @patch("apps.mentorship.api.internal.queries.program.Program.objects.prefetch_related") + def test_my_programs_pagination( + self, + mock_program_prefetch: MagicMock, + mock_mentor_select: MagicMock, + mock_info: MagicMock, + api_program_queries, + ) -> None: + """Test pagination for my_programs query.""" + mock_mentor = MagicMock(spec=Mentor, id=1) + mock_mentor_select.return_value.get.return_value = mock_mentor + + mock_program1 = MagicMock(spec=Program, nest_created_at="2023-01-03", id=1) + mock_program1.admins.all.return_value = [mock_mentor] + mock_program1.modules.return_value.mentors.return_value.github_user.return_value = [] + + mock_program2 = MagicMock(spec=Program, nest_created_at="2023-01-02", id=2) + mock_program2.admins.all.return_value = [] + mock_program2.modules.return_value.mentors.return_value = [mock_mentor] + + mock_program3 = MagicMock(spec=Program, nest_created_at="2023-01-01", id=3) + mock_program3.admins.all.return_value = [mock_mentor] + mock_program3.modules.return_value.mentors.return_value.github_user.return_value = [] + + mock_queryset = MagicMock() + mock_queryset.count.return_value = 3 + mock_queryset.order_by.return_value.__getitem__.return_value = [mock_program2] + mock_queryset.filter.return_value = mock_queryset + + mock_program_prefetch.return_value.filter.return_value.distinct.return_value = ( + mock_queryset + ) + + result = api_program_queries.my_programs(info=mock_info, page=2, limit=1) + + assert len(result.programs) == 1 + assert result.programs[0].id == 2 + assert result.total_pages == 3 + assert result.current_page == 2 diff --git a/backend/tests/apps/mentorship/management/__init__.py b/backend/tests/apps/mentorship/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/mentorship/management/commands/__init__.py b/backend/tests/apps/mentorship/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/mentorship/management/commands/mentorship_sync_issue_levels_test.py b/backend/tests/apps/mentorship/management/commands/mentorship_sync_issue_levels_test.py new file mode 100644 index 0000000000..73097ac072 --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/mentorship_sync_issue_levels_test.py @@ -0,0 +1,121 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from apps.mentorship.management.commands.mentorship_sync_issue_levels import Command + + +@pytest.fixture +def command(): + cmd = Command() + cmd.stdout = MagicMock() + cmd.style = MagicMock() + cmd.style.WARNING = lambda x: x + cmd.style.SUCCESS = lambda x: x + return cmd + + +def make_qs(iterable, exist): + """Return a queryset-like MagicMock that is iterable.""" + qs = MagicMock(name="QuerySet") + qs.exists.return_value = exist + qs.__iter__.return_value = iter(iterable) + qs.all.return_value = list(iterable) + return qs + + +@patch("apps.mentorship.management.commands.mentorship_sync_issue_levels.TaskLevel") +def test_handle_no_task_levels(mock_task_level, command): + """When no TaskLevel objects exist, command should exit early with a warning.""" + empty_qs = make_qs([], exist=False) + mock_task_level.objects.select_related.return_value.order_by.return_value = empty_qs + + command.handle() + command.stdout.write.assert_called_with("No TaskLevel objects found in the database. Exiting.") + + +@patch("apps.mentorship.management.commands.mentorship_sync_issue_levels.Issue") +@patch("apps.mentorship.management.commands.mentorship_sync_issue_levels.TaskLevel") +def test_handle_updates_issues(mock_task_level, mock_issue, command): + """When matches exist, issues should be updated and bulk_update called.""" + mock_module = MagicMock() + mock_module.id = 1 + mock_module.name = "Test Module" + + mock_level_1 = MagicMock(module_id=mock_module.id, name="Beginner", labels=["label-a"]) + mock_level_2 = MagicMock(module_id=mock_module.id, name="Intermediate", labels=["label-b"]) + + levels_qs = make_qs([mock_level_1, mock_level_2], exist=True) + mock_task_level.objects.select_related.return_value.order_by.return_value = levels_qs + + label_a = MagicMock() + label_a.name = "Label-A" + label_b = MagicMock() + label_b.name = "Label-B" + label_nomatch = MagicMock() + label_nomatch.name = "No-Match" + + issue_with_label_a = MagicMock() + issue_with_label_a.labels.all.return_value = [label_a] + issue_with_label_a.mentorship_modules.all.return_value = [mock_module] + issue_with_label_a.level = None + + issue_with_label_b = MagicMock() + issue_with_label_b.labels.all.return_value = [label_b] + issue_with_label_b.mentorship_modules.all.return_value = [mock_module] + issue_with_label_b.level = mock_level_1 + + issue_no_match = MagicMock() + issue_no_match.labels.all.return_value = [label_nomatch] + issue_no_match.mentorship_modules.all.return_value = [mock_module] + issue_no_match.level = mock_level_1 + + issue_already_up_to_date = MagicMock() + issue_already_up_to_date.labels.all.return_value = [label_a] + issue_already_up_to_date.mentorship_modules.all.return_value = [mock_module] + issue_already_up_to_date.level = mock_level_1 + + issues_qs = make_qs( + [issue_with_label_a, issue_with_label_b, issue_no_match, issue_already_up_to_date], + exist=True, + ) + mock_issue.objects.prefetch_related.return_value.select_related.return_value = issues_qs + + command.handle() + + assert issue_with_label_a.level == mock_level_1 + assert issue_with_label_b.level == mock_level_2 + assert issue_no_match.level is None + assert issue_already_up_to_date.level == mock_level_1 + + expected_updated = [issue_with_label_a, issue_with_label_b, issue_no_match] + mock_issue.objects.bulk_update.assert_called_once_with(expected_updated, ["level"]) + command.stdout.write.assert_any_call("Successfully updated the level for 3 issues.") + + +@patch("apps.mentorship.management.commands.mentorship_sync_issue_levels.Issue") +@patch("apps.mentorship.management.commands.mentorship_sync_issue_levels.TaskLevel") +def test_handle_no_updates_needed(mock_task_level, mock_issue, command): + """When all issues already have the correct level, bulk_update is not called.""" + mock_module = MagicMock() + mock_module.id = 1 + + mock_level_1 = MagicMock(module_id=mock_module.id, name="Beginner", labels=["label-a"]) + levels_qs = make_qs([mock_level_1], exist=True) + mock_task_level.objects.select_related.return_value.order_by.return_value = levels_qs + + label_a = MagicMock() + label_a.name = "Label-A" + + issue_already_up_to_date = MagicMock() + issue_already_up_to_date.labels.all.return_value = [label_a] + issue_already_up_to_date.mentorship_modules.all.return_value = [mock_module] + issue_already_up_to_date.level = mock_level_1 + + issues_qs = make_qs([issue_already_up_to_date], exist=True) + mock_issue.objects.prefetch_related.return_value.select_related.return_value = issues_qs + + command.handle() + + mock_issue.objects.bulk_update.assert_not_called() + command.stdout.write.assert_any_call("All issue levels are already up-to-date.") diff --git a/backend/tests/apps/mentorship/management/commands/mentorship_sync_module_issues_test.py b/backend/tests/apps/mentorship/management/commands/mentorship_sync_module_issues_test.py new file mode 100644 index 0000000000..c8026556c9 --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/mentorship_sync_module_issues_test.py @@ -0,0 +1,248 @@ +import datetime as dt +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from django.utils import timezone +from github.GithubException import GithubException + +from apps.mentorship.management.commands.mentorship_sync_module_issues import Command +from apps.mentorship.models.task import Task + + +def make_qs(iterable, exist): + """Return a queryset like MagicMock that is iterable.""" + qs = MagicMock(name="QuerySet") + qs.exists.return_value = exist + qs.__iter__.return_value = iter(iterable) + qs.all.return_value = list(iterable) + return qs + + +@pytest.fixture +def command(): + cmd = Command() + cmd.stdout = MagicMock() + cmd.stderr = MagicMock() + cmd.style = MagicMock() + cmd.style.WARNING = lambda x: x + cmd.style.SUCCESS = lambda x: x + cmd.style.ERROR = lambda x: x + return cmd + + +def test_extract_repo_full_name_from_object(command): + repo = MagicMock() + repo.path = "owner/repo-name" + assert command._extract_repo_full_name(repo) == "owner/repo-name" + + +@pytest.mark.parametrize( + ("repo_url", "expected"), + [ + ("https://github.com/owner/repo", "owner/repo"), + ("https://www.github.com/owner/repo/sub/path", "owner/repo"), + ("https://invalid.com/owner/repo", None), + ("not-a-url", None), + ("https://github.com/owner", None), + (None, None), + ], +) +def test_extract_repo_full_name_from_url(command, repo_url, expected): + assert command._extract_repo_full_name(repo_url) == expected + + +def test_get_status_variants(command): + issue = MagicMock() + issue.state = "closed" + assert command._get_status(issue, MagicMock()) == Task.Status.COMPLETED + + issue.state = "open" + assert command._get_status(issue, MagicMock()) == Task.Status.IN_PROGRESS + assert command._get_status(issue, None) == Task.Status.TODO + + +def test_get_last_assigned_date_found_and_not_found_and_exception(command): + mock_repo = MagicMock() + mock_issue_gh = MagicMock() + mock_repo.get_issue.return_value = mock_issue_gh + + e1 = MagicMock(event="commented", created_at=datetime(2023, 1, 1, tzinfo=dt.UTC)) + e2 = MagicMock( + event="assigned", + assignee=MagicMock(login="other"), + created_at=datetime(2023, 1, 2, tzinfo=dt.UTC), + ) + e3 = MagicMock( + event="assigned", + assignee=MagicMock(login="target"), + created_at=datetime(2023, 1, 3, tzinfo=dt.UTC), + ) + e4 = MagicMock( + event="assigned", + assignee=MagicMock(login="target"), + created_at=datetime(2023, 1, 5, tzinfo=dt.UTC), + ) + mock_issue_gh.get_events.return_value = [e1, e2, e3, e4] + + res = command._get_last_assigned_date(mock_repo, 123, "target") + assert res == datetime(2023, 1, 5, tzinfo=dt.UTC) + + # not found + mock_issue_gh.get_events.return_value = [e1, e2] + res2 = command._get_last_assigned_date(mock_repo, 123, "target") + assert res2 is None + + mock_repo.get_issue.side_effect = GithubException("some gh error") + r3 = command._get_last_assigned_date(mock_repo, 1, "target") + assert r3 is None + assert command.stderr.write.called + + +def test_build_repo_label_to_issue_map_iterable(): + rows = [ + (1, 10, "Label-A"), + (2, 10, "label-b"), + (3, 20, "Label-A"), + (4, 10, "label-a"), + ] + + with patch( + "apps.mentorship.management.commands.mentorship_sync_module_issues.Issue" + ) as mock_issue: + mock_issue.objects.filter.return_value.values_list.return_value.iterator.return_value = ( + iter(rows) + ) + + cmd = Command() + cmd.stdout = MagicMock() + cmd.style = MagicMock() + cmd.style.SUCCESS = lambda x: x + repo_label_map = cmd._build_repo_label_to_issue_map() + + assert (10, "label-a") in repo_label_map + assert repo_label_map[(10, "label-a")] == {1, 4} + assert repo_label_map[(10, "label-b")] == {2} + assert repo_label_map[(20, "label-a")] == {3} + cmd.stdout.write.assert_any_call("Map built. Found issues for 3 unique repo-label pairs.") + + +@patch("apps.mentorship.management.commands.mentorship_sync_module_issues.Task") +@patch("apps.mentorship.management.commands.mentorship_sync_module_issues.Issue") +def test_process_module_links_and_creates_tasks(mock_issue, mock_task, command): + mock_repo = MagicMock() + mock_repo.id = 77 + mock_repo.name = "repo-name" + mock_module = MagicMock() + mock_module.id = 11 + mock_module.name = "Test Module" + mock_module.labels = ["module-label-1"] + mock_project_repo = MagicMock() + mock_project_repo.id = mock_repo.id + mock_project_repo.name = mock_repo.name + mock_module.project.repositories.all.return_value = [mock_project_repo] + mock_task.Status = Task.Status + + repo_label_to_issue_ids = {(mock_repo.id, "module-label-1"): {1, 2, 3}} + + assignee = MagicMock() + assignee.login = "assignee1" + issue1 = MagicMock(id=1, number=1, state="open", repository=mock_project_repo) + issue2 = MagicMock(id=2, number=2, state="closed", repository=mock_project_repo) + issue3 = MagicMock(id=3, number=3, state="open", repository=mock_project_repo) + + issue1.assignees.first.return_value = assignee + issue2.assignees.first.return_value = assignee + issue3.assignees.first.return_value = None + + issues_qs = make_qs([issue1, issue2], exist=True) + mock_issue_filter_related = mock_issue.objects.filter.return_value.select_related.return_value + mock_issue_filter_related.prefetch_related.return_value.distinct.return_value = issues_qs + + created_task1 = MagicMock(module=None, status=None, assigned_at=None) + created_task2 = MagicMock(module=None, status=None, assigned_at=None) + mock_task.objects.get_or_create.side_effect = [ + (created_task1, True), + (created_task2, True), + ] + + with patch( + "apps.mentorship.management.commands.mentorship_sync_module_issues.transaction.atomic" + ) as mock_atomic: + mock_atomic.return_value.__enter__.return_value = None + mock_atomic.return_value.__exit__.return_value = None + + with patch.object(command, "_get_last_assigned_date", return_value=timezone.now()): + num_linked = command._process_module( + module=mock_module, + repo_label_to_issue_ids=repo_label_to_issue_ids, + gh_client=MagicMock(), + repo_cache={}, + verbosity=1, + ) + assert num_linked == 3 + + mock_module.issues.set.assert_called_once_with({1, 2, 3}) + + assert mock_task.objects.get_or_create.call_count == 2 + + calls = mock_task.objects.get_or_create.call_args_list + called_issues = {c.kwargs["issue"] for c in calls} + called_statuses = {c.kwargs["defaults"]["status"] for c in calls} + + assert called_issues == {issue1, issue2} + + assert Task.Status.IN_PROGRESS in called_statuses + assert Task.Status.COMPLETED in called_statuses + + found = False + for c in calls: + if ( + c.kwargs["issue"] is issue1 + and c.kwargs["defaults"]["status"] == Task.Status.IN_PROGRESS + ): + found = True + break + assert found, "expected a get_or_create call for issue1 with IN_PROGRESS status" + + command.stdout.write.assert_any_call( + command.style.SUCCESS( + f"Updated module '{mock_module.name}': set 3 issues from repos: [{ + mock_project_repo.name + }] and created 2 tasks." + ) + ) + + +def test_process_module_no_matches(): + mock_repo = MagicMock() + mock_repo.id = 7 + mock_repo.name = "r" + mock_module = MagicMock() + mock_module.project.repositories.all.return_value = [mock_repo] + mock_module.labels = ["some-label"] + + repo_label_to_issue_ids = {} + + with ( + patch( + "apps.mentorship.management.commands.mentorship_sync_module_issues.Task" + ) as mock_task, + patch( + "apps.mentorship.management.commands.mentorship_sync_module_issues.transaction.atomic" + ) as mock_atomic, + ): + mock_atomic.return_value.__enter__.return_value = None + mock_atomic.return_value.__exit__.return_value = None + + num_linked = Command()._process_module( + module=mock_module, + repo_label_to_issue_ids=repo_label_to_issue_ids, + gh_client=MagicMock(), + repo_cache={}, + verbosity=1, + ) + + mock_module.issues.set.assert_called_once_with(set()) + mock_task.objects.get_or_create.assert_not_called() + assert num_linked == 0 diff --git a/backend/tests/apps/mentorship/management/commands/mentorship_update_comments_test.py b/backend/tests/apps/mentorship/management/commands/mentorship_update_comments_test.py new file mode 100644 index 0000000000..9bc9ec0d5d --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/mentorship_update_comments_test.py @@ -0,0 +1,310 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from apps.mentorship.management.commands.mentorship_update_comments import ( + Command, +) + + +def make_qs(iterable, exist): + """Return a queryset-like MagicMock that is iterable.""" + qs = MagicMock(name="QuerySet") + items = list(iterable) + qs.exists.return_value = exist + qs.__iter__.return_value = iter(items) + qs.all.return_value = items + qs.count.return_value = len(items) + qs.distinct.return_value = qs + return qs + + +def make_user(user_id, login): + user = MagicMock() + user.id = user_id + user.login = login + return user + + +def make_comment(body, author, created_at=None): + c = MagicMock() + c.body = body + c.author = author + c.nest_created_at = created_at or "now" + return c + + +@pytest.fixture +def command(): + cmd = Command() + cmd.stdout = MagicMock() + cmd.style = MagicMock() + cmd.style.WARNING = lambda x: x + cmd.style.SUCCESS = lambda x: x + cmd.style.ERROR = lambda x: x + return cmd + + +@pytest.fixture +def mock_module(): + m = MagicMock() + m.name = "Test Module" + vlist_qs = make_qs([1, 2], exist=True) + m.project.repositories.filter.return_value.values_list.return_value.distinct.return_value = ( + vlist_qs + ) + return m + + +@pytest.fixture +def mock_issue(): + issue = MagicMock() + issue.number = 123 + issue.title = "Test Issue Title" + empty_comments_qs = make_qs([], exist=False) + issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + empty_comments_qs + ) + return issue + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.Module") +def test_process_mentorship_modules_no_published_modules(mock_module, command): + """Test process_mentorship_modules when no published modules exist.""" + mock_module.published_modules.all.return_value.exists.return_value = False + command.process_mentorship_modules() + command.stdout.write.assert_called_with("No published mentorship modules found. Exiting.") + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.Module") +def test_process_mentorship_modules_no_modules_with_labels(mock_module, command): + """Test process_mentorship_modules when no modules with labels exist.""" + mock_module.published_modules.all.return_value.exists.return_value = True + mock_excluded_modules = mock_module.published_modules.all.return_value.exclude.return_value + mock_excluded_modules.select_related.return_value.exists.return_value = False + command.process_mentorship_modules() + command.stdout.write.assert_called_with( + "No published mentorship modules with labels found. Exiting." + ) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.get_github_client") +@patch("apps.mentorship.management.commands.mentorship_update_comments.sync_issue_comments") +@patch.object(Command, "process_issue_interests") +@patch("apps.mentorship.management.commands.mentorship_update_comments.Issue") +def test_process_module( + mock_issue_1, + mock_process_issue_interests, + mock_sync_issue_comments, + mock_get_github_client, + command, + mock_module, + mock_issue, +): + """Test process_module orchestrates issue syncing and interest processing.""" + mock_issue.id = 1 + mock_issue.title = "Test Issue 1" + mock_issue.number = 123 + + issues_qs = make_qs([mock_issue], exist=True) + mock_issue_1.objects.filter.return_value.distinct.return_value = issues_qs + + author = make_user(1, "login") + comment = make_comment("body", author, created_at="now") + comments_qs = make_qs([comment], exist=True) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + + command.process_module(mock_module) + + mock_sync_issue_comments.assert_called_once_with( + mock_get_github_client.return_value, mock_issue + ) + mock_process_issue_interests.assert_called_once_with(mock_issue, mock_module) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.IssueUserInterest") +def test_process_issue_interests_new_interest( + mock_issue_user_interest, command, mock_issue, mock_module +): + """Test process_issue_interests correctly registers new interests.""" + user1 = make_user(1, "user1") + comment1 = make_comment(body="I am /interested", author=user1, created_at="2023-01-01") + + comments_qs = make_qs([comment1], exist=True) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + + mock_issue_user_interest.objects.filter.return_value.values_list.return_value = [] + + mock_issue_user_interest.side_effect = lambda **kw: SimpleNamespace( + module=kw.get("module"), issue=kw.get("issue"), user=kw.get("user") + ) + mock_issue_user_interest.objects.bulk_create = MagicMock() + + command.process_issue_interests(mock_issue, mock_module) + + mock_issue_user_interest.objects.bulk_create.assert_called_once() + created_interest = mock_issue_user_interest.objects.bulk_create.call_args[0][0][0] + assert created_interest.module == mock_module + assert created_interest.issue == mock_issue + assert created_interest.user == user1 + command.stdout.write.assert_any_call( + command.style.SUCCESS("Registered 1 new interest(s) for issue #123: user1") + ) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.IssueUserInterest") +def test_process_issue_interests_remove_interest( + mock_issue_user_interest, command, mock_issue, mock_module +): + """Test process_issue_interests correctly removes interests.""" + user1 = make_user(1, "user1") + comment1 = make_comment(body="Not interested anymore", author=user1, created_at="2023-01-01") + + comments_qs = make_qs([comment1], exist=True) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + + mock_issue_user_interest.objects.filter.return_value.values_list.return_value = [1] + mock_issue_user_interest.objects.filter.return_value.delete.return_value = (1, {}) + + mock_issue_user_interest.side_effect = lambda **kw: SimpleNamespace( + module=kw.get("module"), issue=kw.get("issue"), user=kw.get("user") + ) + mock_issue_user_interest.objects.bulk_create = MagicMock() + + command.process_issue_interests(mock_issue, mock_module) + + mock_issue_user_interest.objects.bulk_create.assert_not_called() + mock_issue_user_interest.objects.filter.assert_any_call( + module=mock_module, issue=mock_issue, user_id__in=[1] + ) + mock_issue_user_interest.objects.filter.return_value.delete.assert_called_once() + command.stdout.write.assert_any_call( + command.style.WARNING("Unregistered 1 interest(s) for issue #123: user1") + ) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.IssueUserInterest") +def test_process_issue_interests_existing_interest_removed( + mock_issue_user_interest, command, mock_issue, mock_module +): + """Existing interest should be removed when user no longer shows interest.""" + mock_issue.number = 1 + + user1 = make_user(1, "user1") + comment1 = make_comment(body="Just a regular comment", author=user1, created_at="2023-01-01") + + comments_qs = make_qs([comment1], exist=True) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + + mock_issue_user_interest.objects.filter.return_value.values_list.return_value = [1] + mock_issue_user_interest.objects.filter.return_value.delete.return_value = (1, {}) + + mock_issue_user_interest.side_effect = lambda **kw: SimpleNamespace( + module=kw.get("module"), issue=kw.get("issue"), user=kw.get("user") + ) + mock_issue_user_interest.objects.bulk_create = MagicMock() + + command.process_issue_interests(mock_issue, mock_module) + + mock_issue_user_interest.objects.bulk_create.assert_not_called() + + mock_issue_user_interest.objects.filter.return_value.delete.assert_called_once() + command.stdout.write.assert_any_call( + command.style.WARNING("Unregistered 1 interest(s) for issue #1: user1") + ) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.IssueUserInterest") +def test_process_issue_interests_multiple_comments_single_user( + mock_issue_user_interest, command, mock_issue, mock_module +): + """Test process_issue_interests with multiple comments from a single user.""" + user1 = make_user(1, "user1") + comment1 = make_comment(body="Some text", author=user1, created_at="2023-01-01") + comment2 = make_comment(body="/interested in this", author=user1, created_at="2023-01-02") + comment3 = make_comment(body="Another comment", author=user1, created_at="2023-01-03") + + comments_qs = make_qs([comment1, comment2, comment3], exist=True) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + + mock_issue_user_interest.objects.filter.return_value.values_list.return_value = [] + + mock_issue_user_interest.side_effect = lambda **kw: SimpleNamespace( + module=kw.get("module"), issue=kw.get("issue"), user=kw.get("user") + ) + mock_issue_user_interest.objects.bulk_create = MagicMock() + + command.process_issue_interests(mock_issue, mock_module) + + mock_issue_user_interest.objects.bulk_create.assert_called_once() + created_interest = mock_issue_user_interest.objects.bulk_create.call_args[0][0][0] + assert created_interest.user == user1 + command.stdout.write.assert_any_call( + command.style.SUCCESS("Registered 1 new interest(s) for issue #123: user1") + ) + + +@patch("apps.mentorship.management.commands.mentorship_update_comments.IssueUserInterest") +def test_process_issue_interests_multiple_users( + mock_issue_user_interest, command, mock_issue, mock_module +): + """Test mixed user interest changes: some created, some removed.""" + mock_issue.number = 123 + + user1 = make_user(1, "user1") + user2 = make_user(2, "user2") + user3 = make_user(3, "user3") + comment1_user1 = make_comment(body="not interested", author=user1, created_at="2023-01-01") + comment2_user2 = make_comment(body="regular comment", author=user2, created_at="2023-01-02") + comment4_user2 = make_comment(body="/interested", author=user2, created_at="2023-01-04") + comment3_user3 = make_comment(body="/interested", author=user3, created_at="2023-01-03") + + comments_qs = make_qs( + [comment1_user1, comment2_user2, comment3_user3, comment4_user2], + exist=True, + ) + mock_issue.comments.select_related.return_value.filter.return_value.order_by.return_value = ( + comments_qs + ) + mock_issue_user_interest.objects.filter.return_value.values_list.return_value = [1] + mock_issue_user_interest.objects.filter.return_value.delete.return_value = (1, {}) + + mock_issue_user_interest.side_effect = lambda **kw: SimpleNamespace( + module=kw.get("module"), issue=kw.get("issue"), user=kw.get("user") + ) + mock_issue_user_interest.objects.bulk_create = MagicMock() + + command.process_issue_interests(mock_issue, mock_module) + assert mock_issue_user_interest.objects.bulk_create.called + created_lists = mock_issue_user_interest.objects.bulk_create.call_args_list + created_users = set() + for call in created_lists: + for created in call[0][0]: + created_users.add(created.user.id) + assert created_users == {2, 3} + filter_calls = mock_issue_user_interest.objects.filter.call_args_list + found_user_id_in_call = any( + ("user_id__in" in c.kwargs and c.kwargs["user_id__in"] == [1]) for c in filter_calls + ) + assert found_user_id_in_call, ( + f"expected filter(..., user_id__in=[1]) in filter calls: {filter_calls}" + ) + mock_issue_user_interest.objects.filter.return_value.delete.assert_called_once() + + command.stdout.write.assert_any_call( + command.style.SUCCESS("Registered 2 new interest(s) for issue #123: user2, user3") + ) + command.stdout.write.assert_any_call( + command.style.WARNING("Unregistered 1 interest(s) for issue #123: user1") + ) diff --git a/backend/tests/apps/mentorship/model/__init__.py b/backend/tests/apps/mentorship/model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/mentorship/model/experience_level_test.py b/backend/tests/apps/mentorship/model/experience_level_test.py new file mode 100644 index 0000000000..e48ed77000 --- /dev/null +++ b/backend/tests/apps/mentorship/model/experience_level_test.py @@ -0,0 +1,9 @@ +from apps.mentorship.models.common.experience_level import ExperienceLevel + + +class TestExperienceLevel: + def test_choices(self): + assert ExperienceLevel.ExperienceLevelChoices.BEGINNER == "beginner" + assert ExperienceLevel.ExperienceLevelChoices.INTERMEDIATE == "intermediate" + assert ExperienceLevel.ExperienceLevelChoices.ADVANCED == "advanced" + assert ExperienceLevel.ExperienceLevelChoices.EXPERT == "expert" diff --git a/backend/tests/apps/mentorship/model/mentee_test.py b/backend/tests/apps/mentorship/model/mentee_test.py new file mode 100644 index 0000000000..914d37cf11 --- /dev/null +++ b/backend/tests/apps/mentorship/model/mentee_test.py @@ -0,0 +1,14 @@ +from unittest.mock import MagicMock + +from apps.github.models import User as GithubUser +from apps.mentorship.models import Mentee + + +class TestMentee: + def test_str_returns_github_login(self): + github_user = MagicMock(spec=GithubUser, login="test_mentee") + + mentee = MagicMock(spec=Mentee) + mentee.github_user = github_user + + assert Mentee.__str__(mentee) == "test_mentee" diff --git a/backend/tests/apps/mentorship/model/mentor_module_test.py b/backend/tests/apps/mentorship/model/mentor_module_test.py new file mode 100644 index 0000000000..d9eeab5a73 --- /dev/null +++ b/backend/tests/apps/mentorship/model/mentor_module_test.py @@ -0,0 +1,18 @@ +from unittest.mock import MagicMock + +from apps.mentorship.models import Mentor, MentorModule, Module + + +class TestMentorModule: + def test_str_returns_readable_identifier(self): + mentor = MagicMock(spec=Mentor) + mentor.__str__.return_value = "test_mentor" + + module = MagicMock(spec=Module) + module.__str__.return_value = "Test Module" + + mentor_module = MagicMock(spec=MentorModule) + mentor_module.mentor = mentor + mentor_module.module = module + + assert MentorModule.__str__(mentor_module) == "test_mentor of Test Module" diff --git a/backend/tests/apps/mentorship/model/mentor_test.py b/backend/tests/apps/mentorship/model/mentor_test.py new file mode 100644 index 0000000000..264f850be5 --- /dev/null +++ b/backend/tests/apps/mentorship/model/mentor_test.py @@ -0,0 +1,14 @@ +from unittest.mock import MagicMock + +from apps.github.models import User as GithubUser +from apps.mentorship.models import Mentor + + +class TestMentor: + def test_str_returns_github_login(self): + github_user = MagicMock(spec=GithubUser, login="test_mentor") + + mentor = MagicMock(spec=Mentor) + mentor.github_user = github_user + + assert Mentor.__str__(mentor) == "test_mentor" diff --git a/backend/tests/apps/mentorship/model/module_test.py b/backend/tests/apps/mentorship/model/module_test.py new file mode 100644 index 0000000000..95f604f4ba --- /dev/null +++ b/backend/tests/apps/mentorship/model/module_test.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock, patch + +import django.utils.timezone + +from apps.mentorship.models import Module, Program +from apps.owasp.models import Project + + +class TestModulePureMocks: + def setup_method(self): + self.program = MagicMock( + spec=Program, + name="Test Program", + key="test-program", + status=Program.ProgramStatus.PUBLISHED, + started_at=django.utils.timezone.datetime( + 2023, 1, 1, 9, 0, 0, tzinfo=django.utils.timezone.UTC + ), + ended_at=django.utils.timezone.datetime( + 2023, 12, 31, 18, 0, 0, tzinfo=django.utils.timezone.UTC + ), + ) + self.project = MagicMock(spec=Project, name="Test Project", key="test-project") + + def test_str_returns_name(self): + """Unit test: __str__ should return the module name (pure mock).""" + mock_module = MagicMock(spec=Module) + mock_module.name = "Security Basics" + + assert Module.__str__(mock_module) == "Security Basics" + + @patch("apps.mentorship.models.Module.objects.create") + def test_module_save(self, mock_create_module): + program_with_dates = MagicMock( + spec=Program, + name="Program With Dates", + key="program-with-dates", + started_at=django.utils.timezone.datetime( + 2024, 2, 1, tzinfo=django.utils.timezone.UTC + ), + ended_at=django.utils.timezone.datetime(2024, 2, 28, tzinfo=django.utils.timezone.UTC), + ) + + mock_module = MagicMock(spec=Module) + mock_module.name = "Auto Date Module" + mock_module.program = program_with_dates + mock_module.project = self.project + mock_module.started_at = None + mock_module.ended_at = None + + def simulate_save_for_inheritance(): + mock_module.key = "date-module" + if not mock_module.started_at: + mock_module.started_at = mock_module.program.started_at + if not mock_module.ended_at: + mock_module.ended_at = mock_module.program.ended_at + + mock_module.save.side_effect = simulate_save_for_inheritance + mock_create_module.return_value = mock_module + + module = Module.objects.create( + name="Auto Date Module", program=program_with_dates, project=self.project + ) + module.save() + + assert module.key == "date-module" + assert module.started_at == django.utils.timezone.datetime( + 2024, 2, 1, tzinfo=django.utils.timezone.UTC + ) + assert module.ended_at == django.utils.timezone.datetime( + 2024, 2, 28, tzinfo=django.utils.timezone.UTC + ) + + explicit_start = django.utils.timezone.datetime( + 2024, 3, 1, tzinfo=django.utils.timezone.UTC + ) + explicit_end = django.utils.timezone.datetime( + 2024, 3, 31, tzinfo=django.utils.timezone.UTC + ) + + mock_module.started_at = explicit_start + mock_module.ended_at = explicit_end + mock_create_module.return_value = mock_module + + module = Module.objects.create( + name="Auto Date Module", program=program_with_dates, project=self.project + ) + module.save() + + assert module.started_at == explicit_start + assert module.ended_at == explicit_end + assert module.key == "date-module" diff --git a/backend/tests/apps/mentorship/model/program_test.py b/backend/tests/apps/mentorship/model/program_test.py new file mode 100644 index 0000000000..e5f0f072cc --- /dev/null +++ b/backend/tests/apps/mentorship/model/program_test.py @@ -0,0 +1,15 @@ +from unittest.mock import MagicMock + +from apps.mentorship.models import Program + + +class TestProgram: + def test_program_status_choices(self): + assert Program.ProgramStatus.DRAFT == "draft" + assert Program.ProgramStatus.PUBLISHED == "published" + assert Program.ProgramStatus.COMPLETED == "completed" + + def test_str_returns_name(self): + mock_program_instance = MagicMock(spec=Program) + mock_program_instance.name = "Security Program" + assert Program.__str__(mock_program_instance) == "Security Program" diff --git a/backend/tests/apps/mentorship/model/task_level_test.py b/backend/tests/apps/mentorship/model/task_level_test.py new file mode 100644 index 0000000000..a16c7fb98e --- /dev/null +++ b/backend/tests/apps/mentorship/model/task_level_test.py @@ -0,0 +1,26 @@ +from unittest.mock import MagicMock + +from apps.mentorship.models import Module, TaskLevel + + +class TestTaskLevelUnit: + def test_str_returns_module_name_and_task_name(self): + module = MagicMock(spec=Module) + module.name = "Test Module" + + task_level = MagicMock(spec=TaskLevel) + task_level.module = module + task_level.name = "Beginner Task" + + assert TaskLevel.__str__(task_level) == "Test Module - Beginner Task" + + def test_str_handles_module_with_custom_str(self): + module = MagicMock(spec=Module) + module.name = "Custom Module" + module.__str__.return_value = "ignored-str" + + task_level = MagicMock(spec=TaskLevel) + task_level.module = module + task_level.name = "Advanced Task" + + assert TaskLevel.__str__(task_level) == "Custom Module - Advanced Task" diff --git a/backend/tests/apps/mentorship/model/task_test.py b/backend/tests/apps/mentorship/model/task_test.py new file mode 100644 index 0000000000..8dc078d4ef --- /dev/null +++ b/backend/tests/apps/mentorship/model/task_test.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock + +from apps.github.models import Issue +from apps.github.models import User as GithubUser +from apps.mentorship.models import Task + + +class TestTaskUnit: + def test_str_when_assigned(self): + issue = MagicMock(spec=Issue) + issue.title = "Task Issue Title" + + assignee = MagicMock(spec=GithubUser) + assignee.login = "task_assignee" + + task = MagicMock(spec=Task) + task.issue = issue + task.assignee = assignee + + expected = f"Task: '{issue.title}' assigned to {assignee.login}" + assert Task.__str__(task) == expected + + def test_str_when_unassigned(self): + issue = MagicMock(spec=Issue) + issue.title = "Task Issue Title" + + task = MagicMock(spec=Task) + task.issue = issue + task.assignee = None + + expected = f"Task: {issue.title} (Unassigned)" + assert Task.__str__(task) == expected + + def test_status_constants(self): + assert Task.Status.TODO == "TODO" + assert Task.Status.IN_PROGRESS == "IN_PROGRESS" + assert Task.Status.IN_REVIEW == "IN_REVIEW" + assert Task.Status.COMPLETED == "COMPLETED"