From 4afa98230d3578c40dfd805c70bdedb1f9831055 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:32:12 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`test=5F?= =?UTF-8?q?mentorship=5Fbackend`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @kart-u. * https://github.com/OWASP/Nest/pull/3179#issuecomment-3719436174 The following files were modified: * `backend/apps/mentorship/api/internal/nodes/mentee.py` * `backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_mentor.py` * `backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_module.py` * `backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_program.py` * `backend/tests/apps/mentorship/api/internal/queries/test_api_queries_mentorship.py` * `backend/tests/apps/mentorship/api/internal/queries/test_api_queries_program.py` * `backend/tests/apps/mentorship/management/commands/test_mentorship_sync_issue_levels.py` * `backend/tests/apps/mentorship/management/commands/test_mentorship_sync_module_issues.py` * `backend/tests/apps/mentorship/management/commands/test_mentorship_update_comments.py` * `backend/tests/apps/mentorship/model/test_module.py` --- .../mentorship/api/internal/nodes/mentee.py | 9 +- .../nodes/test_api_internal_mentor.py | 91 ++++ .../nodes/test_api_internal_module.py | 475 ++++++++++++++++++ .../nodes/test_api_internal_program.py | 181 +++++++ .../queries/test_api_queries_mentorship.py | 421 ++++++++++++++++ .../queries/test_api_queries_program.py | 205 ++++++++ .../test_mentorship_sync_issue_levels.py | 134 +++++ .../test_mentorship_sync_module_issues.py | 223 ++++++++ .../test_mentorship_update_comments.py | 315 ++++++++++++ .../apps/mentorship/model/test_module.py | 94 ++++ 10 files changed, 2146 insertions(+), 2 deletions(-) create mode 100644 backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_mentor.py create mode 100644 backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_module.py create mode 100644 backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_program.py create mode 100644 backend/tests/apps/mentorship/api/internal/queries/test_api_queries_mentorship.py create mode 100644 backend/tests/apps/mentorship/api/internal/queries/test_api_queries_program.py create mode 100644 backend/tests/apps/mentorship/management/commands/test_mentorship_sync_issue_levels.py create mode 100644 backend/tests/apps/mentorship/management/commands/test_mentorship_sync_module_issues.py create mode 100644 backend/tests/apps/mentorship/management/commands/test_mentorship_update_comments.py create mode 100644 backend/tests/apps/mentorship/model/test_module.py diff --git a/backend/apps/mentorship/api/internal/nodes/mentee.py b/backend/apps/mentorship/api/internal/nodes/mentee.py index 20283410de..8c7a7c465b 100644 --- a/backend/apps/mentorship/api/internal/nodes/mentee.py +++ b/backend/apps/mentorship/api/internal/nodes/mentee.py @@ -26,5 +26,10 @@ def resolve_avatar_url(self) -> str: @strawberry.field(name="experienceLevel") def resolve_experience_level(self) -> str: - """Get the experience level of the mentee.""" - return self.experience_level if self.experience_level else "beginner" + """ + Return the mentee's experience level, defaulting to "beginner" when not set. + + Returns: + experience_level (str): The mentee's experience level, or "beginner" if the field is empty or falsy. + """ + return self.experience_level if self.experience_level else "beginner" \ No newline at end of file diff --git a/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_mentor.py b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_mentor.py new file mode 100644 index 0000000000..75695c1191 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_mentor.py @@ -0,0 +1,91 @@ +"""Pytest for mentorship mentor nodes.""" + +from unittest.mock import MagicMock + +import pytest + +from apps.mentorship.api.internal.nodes.mentor import MentorNode + + +@pytest.fixture +def mock_github_user(): + """ + Create a MagicMock that simulates a GitHub user for tests. + + The mock has the following attributes set: + - `avatar_url`: "https://example.com/mentor_avatar.jpg" + - `name`: "Mentor Name" + - `login`: "mentorlogin" + + Returns: + MagicMock: A mock object representing a GitHub user with the above attributes. + """ + mock = MagicMock() + mock.avatar_url = "https://example.com/mentor_avatar.jpg" + mock.name = "Mentor Name" + mock.login = "mentorlogin" + return mock + + +@pytest.fixture +def mock_mentor_node(mock_github_user): + """ + Create a MentorNode with its `github_user` set to the provided mock. + + Parameters: + mock_github_user: Mocked GitHub user object (e.g., MagicMock) providing attributes like `avatar_url`, `name`, and `login`. + + Returns: + MentorNode: A MentorNode with id `"1"` and `github_user` assigned to the provided mock. + """ + mentor_node = MentorNode(id="1") + mentor_node.github_user = mock_github_user + return mentor_node + + +@pytest.fixture +def mock_mentor_node_no_github_user(): + """ + Create a MentorNode test fixture with no associated GitHub user. + + Returns: + MentorNode: a MentorNode with id "2" and github_user set to None. + """ + 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() == "mentorlogin" + + +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() == "" \ No newline at end of file diff --git a/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_module.py b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_module.py new file mode 100644 index 0000000000..5cedbaa062 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_module.py @@ -0,0 +1,475 @@ +"""Fixed tests for ModuleNode resolvers: use a small fake ModuleNode object +so resolver methods actually run (instead of calling MagicMock methods).""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import strawberry +import pytest + +from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum +from apps.mentorship.api.internal.nodes.module import CreateModuleInput, UpdateModuleInput + + +class FakeModuleNode: + """A minimal object that implements ModuleNode resolver behavior by delegating + to attributes that tests can set (e.g. ._issues_qs, .menteemodule_set, managers).""" + + def __init__(self): + # basic scalar attrs + """ + Initialize a FakeModuleNode with default scalar fields and mock managers used by resolver tests. + + Sets realistic default values for scalar attributes (id, key, name, description, domains, started_at, ended_at, experience_level, labels, tags, program.{id,key}, project_id) and constructs replaceable mock attributes used by resolver methods: `_mentors_manager` (must implement `.all()`), `menteemodule_set`, `_issues_qs`, and `project` (with `name`). These mocks are intended for tests to stub queryset/manager chains and control resolver behavior. + """ + 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) + 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) + self.tags = ["backend", "frontend"] + + # attributes that resolver methods will use (tests can replace these) + # mentors manager must expose .all() + self._mentors_manager = MagicMock() + # mentee-module relationship manager chain used in mentees() + self.menteemodule_set = MagicMock() + # issues queryset-like object (tests will set return values on chains) + self._issues_qs = MagicMock() + # project object + self.project = MagicMock() + self.project.name = "Test Project" + + # Implement resolver methods by delegating to the attributes above, + # matching original ModuleNode logic but acting on attributes tests control. + def mentors(self): + """ + Return the mentors associated with this module. + + Returns: + QuerySet: A QuerySet of mentor objects related to the module. + """ + return self._mentors_manager.all() + + def mentees(self): + """ + Return GitHub users for the module's mentees that have linked GitHub accounts. + + Returns: + list[GithubUser]: List of GitHub User objects linked to mentees, ordered by `login`. + """ + mentee_users = ( + self.menteemodule_set.select_related("mentee__github_user") + .filter(mentee__github_user__isnull=False) + .values_list("mentee__github_user", flat=True) + ) + + # The tests patch apps.github.models.user.User.objects + from apps.github.models.user import User as GithubUser + + return list(GithubUser.objects.filter(id__in=mentee_users).order_by("login")) + + def issue_mentees(self, issue_number: int): + """ + Return GitHub users who were assigned tasks for the given issue within this module. + + Parameters: + issue_number (int): The issue number to look up within this module. + + Returns: + list: A list of `apps.github.models.user.User` objects representing assignees on tasks associated with the issue, ordered by `login`. Returns an empty list if the issue has no associated tasks or assignees. + """ + issue_ids = list(self._issues_qs.filter(number=issue_number).values_list("id", flat=True)) + if not issue_ids: + return [] + from apps.mentorship.models.task import Task # tests patch Task.objects + from apps.github.models.user import User as GithubUser + + mentee_users = ( + Task.objects.filter(module=self, issue_id__in=issue_ids, assignee__isnull=False) + .select_related("assignee") + .values_list("assignee", flat=True) + .distinct() + ) + + return list(GithubUser.objects.filter(id__in=mentee_users).order_by("login")) + + def project_name(self): + """ + Get the project's name for this module, or None if no project is associated. + + Returns: + The project's name as a string, or None when the module has no project. + """ + return self.project.name if self.project else None + + # keep method name 'issues' (so tests call mock_module_node.issues()) + def issues(self, limit: int = 20, offset: int = 0, label: str | None = None): + """ + Get issues for the module, optionally filtered by label and paginated. + + Parameters: + limit (int): Maximum number of issues to return. + offset (int): Number of issues to skip before collecting results. + label (str | None): If provided and not "all", restrict results to issues with this label name. + + Returns: + list: Issue objects ordered by `updated_at` descending, filtered and sliced according to `label`, `offset`, and `limit`. + """ + queryset = self._issues_qs.select_related("repository", "author").prefetch_related("assignees", "labels") + if label and label != "all": + queryset = queryset.filter(labels__name=label) + return list(queryset.order_by("-updated_at")[offset: offset + limit]) + + def issues_count(self, label: str | None = None): + """ + Return the number of issues associated with the module, optionally filtered by label. + + Parameters: + label (str | None): Name of the label to filter issues by. If `None` or `"all"`, no label filter is applied. + + Returns: + int: The count of issues matching the module and optional label filter. + """ + queryset = self._issues_qs + if label and label != "all": + queryset = queryset.filter(labels__name=label) + return queryset.count() + + def available_labels(self): + """ + Get sorted label names associated with this module. + + Returns: + list[str]: Distinct label names related to this module, sorted in ascending order. + """ + from apps.github.models import Label + + label_names = ( + Label.objects.filter(issue__mentorship_modules=self) + .values_list("name", flat=True) + .distinct() + ) + return sorted(label_names) + + def issue_by_number(self, number: int): + """ + Retrieve the issue with the given issue number for this module. + + Parameters: + number (int): The issue number to look up within this module. + + Returns: + Issue or None: The matching issue (with repository, author, assignees, and labels prefetched) if found, otherwise None. + """ + return ( + self._issues_qs.select_related("repository", "author") + .prefetch_related("assignees", "labels") + .filter(number=number) + .first() + ) + + def interested_users(self, issue_number: int): + """ + Return GitHub users who expressed interest in the specified issue for this module, ordered by user login. + + Parameters: + issue_number (int): The issue number to match within this module. + + Returns: + list: A list of `User` objects who expressed interest in the issue, ordered by `login`; an empty list if no interests are found. + """ + issue_ids = list(self._issues_qs.filter(number=issue_number).values_list("id", flat=True)) + if not issue_ids: + return [] + from apps.mentorship.models.issue_user_interest import IssueUserInterest + + interests = ( + IssueUserInterest.objects.select_related("user") + .filter(module=self, issue_id__in=issue_ids) + .order_by("user__login") + ) + return [i.user for i in interests] + + def task_deadline(self, issue_number: int): + """ + Retrieve the most recent task deadline for this module associated with a given issue number. + + Parameters: + issue_number (int): The issue's numeric identifier to match. + + Returns: + datetime or None: The latest `deadline_at` value for a task linked to the issue, or `None` if no deadline exists. + """ + from apps.mentorship.models.task import Task + + return ( + Task.objects.filter( + module=self, + issue__number=issue_number, + deadline_at__isnull=False, + ) + .order_by("-assigned_at") + .values_list("deadline_at", flat=True) + .first() + ) + + def task_assigned_at(self, issue_number: int): + """ + Get the most recent task assignment timestamp for this module and a given issue number. + + Parameters: + issue_number (int): The issue number to match tasks against. + + Returns: + datetime or None: The latest `assigned_at` timestamp for tasks associated with this module and issue number, or `None` if no matching assignment exists. + """ + from apps.mentorship.models.task import Task + + return ( + Task.objects.filter( + module=self, + issue__number=issue_number, + assigned_at__isnull=False, + ) + .order_by("-assigned_at") + .values_list("assigned_at", flat=True) + .first() + ) + + +@pytest.fixture +def mock_module_node(): + """ + Create and return a FakeModuleNode preconfigured with sensible MagicMock return values for tests. + + This fixture builds a FakeModuleNode and configures common queryset/manager call chains used across the module's resolver tests: + - _mentors_manager.all() returns two mock mentors. + - menteemodule_set.select_related(...).filter(...).values_list(...) returns two GitHub user IDs. + - _issues_qs.filter(...).values_list(...) returns a single issue ID. + - _issues_qs.select_related(...).prefetch_related(...).filter(...).order_by(...) returns a list with one mock issue. + - _issues_qs.count() returns 5. + - _issues_qs.select_related(...).prefetch_related(...).filter(...).first() returns a mock issue. + + Returns: + FakeModuleNode: A FakeModuleNode instance with its internals mocked for use in tests. + """ + m = FakeModuleNode() + + # prepare sensible default returns for chains used by tests + m._mentors_manager.all.return_value = [MagicMock(), MagicMock()] + # mentee module set -> values_list returns two ids by default + m.menteemodule_set.select_related.return_value.filter.return_value.values_list.return_value = [ + "github_user_id_1", + "github_user_id_2", + ] + # issues.filter(...).values_list(...) used by issue_mentees default + m._issues_qs.filter.return_value.values_list.return_value = ["issue_id_1"] + # issues.select_related(...).prefetch_related(...).filter(...).order_by(...) returns list of 1 mock issue + m._issues_qs.select_related.return_value.prefetch_related.return_value.filter.return_value.order_by.return_value = [ + MagicMock() + ] + m._issues_qs.count.return_value = 5 + m._issues_qs.select_related.return_value.prefetch_related.return_value.filter.return_value.first.return_value = MagicMock() + + return m + + +def test_module_node_fields(mock_module_node): + 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) + 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) + assert mock_module_node.tags == ["backend", "frontend"] + + +def test_module_node_mentors(mock_module_node): + mentors = mock_module_node.mentors() + assert len(mentors) == 2 + mock_module_node._mentors_manager.all.assert_called_once() + + +def test_module_node_mentees(mock_module_node): + # patch the User manager used inside the resolver to return two mock users + 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.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): + 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_objects.filter.return_value.select_related.return_value.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.issue_mentees(issue_number=123) + assert len(mentees) == 1 + mock_module_node._issues_qs.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): + mock_module_node._issues_qs.filter.return_value.values_list.return_value = [] + mentees = mock_module_node.issue_mentees(issue_number=123) + assert mentees == [] + + +def test_module_node_project_name(mock_module_node): + assert mock_module_node.project_name() == "Test Project" + + +def test_module_node_project_name_no_project(): + mock = FakeModuleNode() + mock.project = None + assert mock.project_name() is None + + +def test_module_node_issues_with_label(mock_module_node): + issues_list = mock_module_node.issues(label="bug") + assert len(issues_list) == 1 + mock_module_node._issues_qs.select_related.return_value.prefetch_related.return_value.filter.assert_called_once_with( + labels__name="bug" + ) + + +def test_module_node_issues_count(mock_module_node): + count = mock_module_node.issues_count() + assert count == 5 + mock_module_node._issues_qs.count.assert_called_once() + + +def test_module_node_issues_count_with_label(mock_module_node): + mock_module_node._issues_qs.filter.return_value.count.return_value = 2 + count = mock_module_node.issues_count(label="feature") + assert count == 2 + mock_module_node._issues_qs.filter.assert_called_once_with(labels__name="feature") + + +def test_module_node_available_labels(mock_module_node): + 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.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): + issue = mock_module_node.issue_by_number(number=456) + assert issue is not None + mock_module_node._issues_qs.select_related.assert_called_once_with("repository", "author") + mock_module_node._issues_qs.select_related.return_value.prefetch_related.assert_called_once_with("assignees", "labels") + mock_module_node._issues_qs.select_related.return_value.prefetch_related.return_value.filter.assert_called_once_with(number=456) + mock_module_node._issues_qs.select_related.return_value.prefetch_related.return_value.filter.return_value.first.assert_called_once() + + +def test_module_node_interested_users(mock_module_node): + 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_issue_user_interest_objects.select_related.return_value.filter.return_value.order_by.return_value = [ + mock_interest1, mock_interest2 + ] + + users = mock_module_node.interested_users(issue_number=789) + assert len(users) == 2 + assert users[0].login == "user1" + assert users[1].login == "user2" + mock_module_node._issues_qs.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): + mock_module_node._issues_qs.filter.return_value.values_list.return_value = [] + users = mock_module_node.interested_users(issue_number=789) + assert users == [] + + +def test_module_node_task_deadline(mock_module_node): + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_objects.filter.return_value.order_by.return_value.values_list.return_value.first.return_value = datetime(2025, 10, 26) + + deadline = mock_module_node.task_deadline(issue_number=101) + assert deadline == datetime(2025, 10, 26) + 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): + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_objects.filter.return_value.order_by.return_value.values_list.return_value.first.return_value = None + + deadline = mock_module_node.task_deadline(issue_number=101) + assert deadline is None + + +def test_module_node_task_assigned_at(mock_module_node): + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_objects.filter.return_value.order_by.return_value.values_list.return_value.first.return_value = datetime(2025, 9, 15) + + assigned_at = mock_module_node.task_assigned_at(issue_number=202) + assert assigned_at == datetime(2025, 9, 15) + 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): + with patch("apps.mentorship.models.task.Task.objects") as mock_task_objects: + mock_task_objects.filter.return_value.order_by.return_value.values_list.return_value.first.return_value = None + + assigned_at = mock_module_node.task_assigned_at(issue_number=202) + assert assigned_at is None + + +def test_create_update_input_defaults(): + # CreateModuleInput / UpdateModuleInput defaults sanity checks + create_input = CreateModuleInput( + name="Test", description="Desc", ended_at=datetime.now(), + experience_level=ExperienceLevelEnum.BEGINNER, program_key="key", + project_name="Project", project_id="id", started_at=datetime.now() + ) + 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(), experience_level=ExperienceLevelEnum.BEGINNER, + project_id="id", project_name="Project", started_at=datetime.now() + ) + assert update_input.domains == [] + assert update_input.labels == [] + assert update_input.tags == [] \ No newline at end of file diff --git a/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_program.py b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_program.py new file mode 100644 index 0000000000..a24d67425b --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/nodes/test_api_internal_program.py @@ -0,0 +1,181 @@ +"""Pytest for mentorship program nodes (fixed fixture: use a FakeProgramNode so +admins() resolver actually runs and we only mock the manager).""" + +from datetime import datetime +from unittest.mock import MagicMock + +import strawberry +import pytest + +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: + """Minimal ProgramNode-like object that implements the admins() resolver + while letting tests control the underlying admins manager. + """ + + def __init__(self): + """ + Initialize a FakeProgramNode with fixed test values and a mocked admins manager. + + Creates a node pre-populated with realistic sample fields used in tests: + - id: strawberry.ID("prog-1") + - key, name, description: identifying metadata + - domains: list of domain strings + - started_at, ended_at: datetimes for the program period + - experience_levels: list of ExperienceLevelEnum members + - mentees_limit: integer cap for mentees + - status: ProgramStatusEnum value + - user_role: role string for the current user + - tags: list of tag strings + + Attributes: + id (strawberry.ID): Stable test identifier "prog-1". + key (str): Program key, "test-program". + name (str): Human-readable name, "Test Program". + description (str): Short description of the program. + domains (list[str]): Domains associated with the program. + ended_at (datetime): Program end date. + experience_levels (list[ExperienceLevelEnum]): Allowed experience levels. + mentees_limit (int): Maximum number of mentees. + started_at (datetime): Program start date. + status (ProgramStatusEnum): Current program status. + user_role (str): Role of the current user for this node. + tags (list[str]): Tags associated with the program. + _admins_manager (MagicMock): Internal manager mocked in tests; its `all()` method is used by the `admins()` resolver. + """ + 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) + self.experience_levels = [ExperienceLevelEnum.BEGINNER, ExperienceLevelEnum.INTERMEDIATE] + self.mentees_limit = 10 + self.started_at = datetime(2026, 1, 1) + self.status = ProgramStatusEnum.PUBLISHED + self.user_role = "admin" + self.tags = ["python", "javascript"] + + # internal manager that tests will set up + self._admins_manager = MagicMock() + + # the real resolver code should behave similarly: return the manager's .all() + def admins(self): + """ + Retrieve admin objects associated with this program node. + + Returns: + list: Admin objects associated with this node. + """ + return self._admins_manager.all() + + +@pytest.fixture +def mock_program_node(): + """Fixture returning a FakeProgramNode with a mocked admins manager.""" + node = FakeProgramNode() + + node._admins_manager.all.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) + 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) + 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.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"] == str + assert CreateProgramInput.__annotations__["description"] == str + assert CreateProgramInput.__annotations__["domains"] == list[str] + assert CreateProgramInput.__annotations__["ended_at"] == datetime + assert CreateProgramInput.__annotations__["mentees_limit"] == int + assert CreateProgramInput.__annotations__["started_at"] == datetime + assert CreateProgramInput.__annotations__["tags"] == list[str] + + create_input = CreateProgramInput( + name="New Program", + description="Description for new program", + ended_at=datetime.now(), + mentees_limit=5, + started_at=datetime.now(), + ) + 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"] == str + assert UpdateProgramInput.__annotations__["name"] == str + assert UpdateProgramInput.__annotations__["description"] == str + assert UpdateProgramInput.__annotations__["admin_logins"] == list[str] | None + assert UpdateProgramInput.__annotations__["domains"] == list[str] | None + assert UpdateProgramInput.__annotations__["ended_at"] == datetime + assert UpdateProgramInput.__annotations__["mentees_limit"] == int + assert UpdateProgramInput.__annotations__["started_at"] == datetime + assert UpdateProgramInput.__annotations__["status"] == 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(), + mentees_limit=12, + started_at=datetime.now(), + 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"] == str + assert UpdateProgramStatusInput.__annotations__["name"] == str + assert UpdateProgramStatusInput.__annotations__["status"] == ProgramStatusEnum \ No newline at end of file diff --git a/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_mentorship.py b/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_mentorship.py new file mode 100644 index 0000000000..c90960081b --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_mentorship.py @@ -0,0 +1,421 @@ +"""Pytest for mentorship queries (fixed patch targets).""" +from unittest.mock import MagicMock, patch + +import pytest +from django.core.exceptions import ObjectDoesNotExist + +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, MenteeModule, Module, Mentor + + +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) -> 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 + + query = MentorshipQuery() + result = query.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) -> 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 + + query = MentorshipQuery() + result = query.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) -> None: + """Test that is_mentor returns False when the GitHub user does not exist.""" + mock_github_user_get.side_effect = GithubUser.DoesNotExist + + query = MentorshipQuery() + result = query.is_mentor(login="nonexistentuser") + + assert result is False + mock_github_user_get.assert_called_once_with(login="nonexistentuser") + + @pytest.mark.parametrize("login", ["", " ", None]) + def test_is_mentor_false_empty_login(self, login: str | None) -> None: + """Test that is_mentor returns False for empty or whitespace login.""" + query = MentorshipQuery() + result = query.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, + ) -> None: + """ + Verify get_mentee_details returns a MenteeNode populated from Module, GithubUser, and Mentee lookups. + + Asserts the returned node contains the expected login, name, experience_level, and tags, and that + Module.objects.only().get, GithubUser.objects.only().get, Mentee.objects.only().get, and + MenteeModule.objects.filter(...).exists() are invoked with the expected arguments. + """ + 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="testmentee" + 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 + + query = MentorshipQuery() + result = query.get_mentee_details( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + assert isinstance(result, MenteeNode) + assert result.login == "testmentee" + 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="testmentee") + 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) -> None: + """Test when the module does not exist.""" + mock_module_only.return_value.get.side_effect = Module.DoesNotExist + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee details not found: "): + query.get_mentee_details( + program_key="program", module_key="nonexistent", mentee_key="testmentee" + ) + + @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 + ) -> 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 + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee details not found: "): + query.get_mentee_details( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + + @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, + ) -> 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="testmentee") + mock_mentee_only.return_value.get.side_effect = Mentee.DoesNotExist + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee details not found: "): + query.get_mentee_details( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + @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, + ) -> 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="testmentee") + mock_mentee_only.return_value.get.return_value = MagicMock(spec=Mentee, id=2) + mock_mentee_module_filter.return_value.exists.return_value = False + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee testmentee is not enrolled in module module"): + query.get_mentee_details( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + +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 + ) -> 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="testmentee") + 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] + + # chain mocks to simulate module.issues.filter().only().prefetch_related().order_by() + mock_module.issues.filter.return_value.only.return_value.prefetch_related.return_value.order_by.return_value = ( + mock_issues_qs + ) + + query = MentorshipQuery() + result = query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + 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.assert_called_with('id', 'login', 'name', 'avatar_url') + mock_github_user_only.return_value.get.assert_called_once_with(login="testmentee") + 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) -> None: + """Test when the module does not exist.""" + mock_module_only.return_value.get.side_effect = Module.DoesNotExist + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee issues not found: "): + query.get_mentee_module_issues( + program_key="program", module_key="nonexistent", mentee_key="testmentee" + ) + + @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 + ) -> 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 + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee issues not found: "): + query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="nonexistent" + ) + + @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, + ) -> 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="testmentee") + mock_mentee_only.return_value.get.side_effect = Mentee.DoesNotExist + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee issues not found: "): + query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + @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, + ) -> 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="testmentee") + mock_mentee_only.return_value.get.return_value = MagicMock(spec=Mentee, id=2) + mock_mentee_module_filter.return_value.exists.return_value = False + + query = MentorshipQuery() + with pytest.raises(ObjectDoesNotExist, match="Mentee testmentee is not enrolled in module module"): + query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="testmentee" + ) + + @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 + ) -> None: + """ + Verify that get_mentee_module_issues returns the correct slice of issues when pagination parameters are applied. + + Asserts the query returns the expected issues for the given limit and offset and that the issues queryset is filtered by the mentee's GitHub user (assignees). + """ + 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="testmentee") + 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.issues.filter.return_value.only.return_value.prefetch_related.return_value.order_by.return_value = ( + mock_issues_qs + ) + + query = MentorshipQuery() + result = query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="testmentee", 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 + ) -> None: + """ + Verify that requesting issues with an offset beyond the available range returns an empty list. + + This test sets up a module, GitHub user, and mentee, ensures the mentee is enrolled in the module, configures the module's issues queryset to return no items for the requested slice, and asserts that get_mentee_module_issues(...) returns an empty list when offset is out of range. + """ + 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="testmentee") + 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.issues.filter.return_value.only.return_value.prefetch_related.return_value.order_by.return_value = ( + mock_issues_qs + ) + + query = MentorshipQuery() + result = query.get_mentee_module_issues( + program_key="program", module_key="module", mentee_key="testmentee", limit=10, offset=1000 + ) + + assert result == [] \ No newline at end of file diff --git a/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_program.py b/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_program.py new file mode 100644 index 0000000000..a5a25c50b9 --- /dev/null +++ b/backend/tests/apps/mentorship/api/internal/queries/test_api_queries_program.py @@ -0,0 +1,205 @@ +"""Pytest for mentorship program queries.""" +from unittest.mock import MagicMock, patch + +import pytest +import strawberry +from django.core.exceptions import ObjectDoesNotExist + +from apps.github.models import User as GithubUser +from apps.mentorship.api.internal.nodes.program import PaginatedPrograms, ProgramNode +from apps.mentorship.api.internal.queries.program import ProgramQuery +from apps.mentorship.models import Mentor, Program + + +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) -> 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 + + query = ProgramQuery() + result = query.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) -> None: + """Test when the program does not exist.""" + mock_program_prefetch_related.return_value.get.side_effect = Program.DoesNotExist + + query = ProgramQuery() + with pytest.raises(ObjectDoesNotExist, match="Program with key 'nonexistent' not found."): + query.get_program(program_key="nonexistent") + + 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.""" + + @pytest.fixture + def mock_info(self) -> MagicMock: + """ + Create a MagicMock strawberry.Info whose context.request.user.github_user is a mocked GithubUser. + + Returns: + MagicMock: A mock `strawberry.Info` object with `context.request.user` set to a mock user that has a `github_user` mock (id=1, login="testuser"). + """ + 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 + + @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) -> 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 + + query = ProgramQuery() + result = query.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) -> None: + """Test when the current user is not a mentor.""" + mock_mentor_select.return_value.get.side_effect = Mentor.DoesNotExist + + query = ProgramQuery() + result = query.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) -> 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 + + query = ProgramQuery() + result = query.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) -> None: + """ + Verify that my_programs applies a name search filter and returns matching programs. + + Asserts that the mentor is resolved, the Program queryset is filtered with name__icontains equal to the provided search string, and the resolver returns the expected program in the PaginatedPrograms result. + """ + 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 + + query = ProgramQuery() + result = query.my_programs(info=mock_info, search="test") + + assert len(result.programs) == 1 + mock_program_prefetch.return_value.filter.return_value.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) -> None: + """ + Verify my_programs returns the correct page of programs and accurate pagination metadata when results span multiple pages. + + Asserts that requesting page 2 with a limit of 1 yields the program with id 2, total_pages equals 3, and current_page equals 2. + """ + 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 + + query = ProgramQuery() + result = query.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/commands/test_mentorship_sync_issue_levels.py b/backend/tests/apps/mentorship/management/commands/test_mentorship_sync_issue_levels.py new file mode 100644 index 0000000000..b964497f27 --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/test_mentorship_sync_issue_levels.py @@ -0,0 +1,134 @@ +import pytest +from unittest.mock import MagicMock, patch + +from apps.mentorship.management.commands.mentorship_sync_issue_levels import Command + + +@pytest.fixture +def command(): + """ + Create and return a Command instance prepared for tests with mocked output and styling. + + Returns: + cmd (Command): A Command instance whose `stdout` is a MagicMock and whose `style` provides `WARNING` and `SUCCESS` callables that return their input. + """ + 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, exists=True): + """ + Create a MagicMock that behaves like a queryset for tests. + + Parameters: + iterable (iterable): Items to be yielded by iteration and returned by `.all()`. + exists (bool): Value that `.exists()` will return. + + Returns: + MagicMock: A mock queryset configured with `.exists()`, an iterator, and `.all()`. + """ + qs = MagicMock(name="QuerySet") + qs.exists.return_value = exists + 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([], exists=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], exists=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], exists=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], exists=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], exists=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.") \ No newline at end of file diff --git a/backend/tests/apps/mentorship/management/commands/test_mentorship_sync_module_issues.py b/backend/tests/apps/mentorship/management/commands/test_mentorship_sync_module_issues.py new file mode 100644 index 0000000000..87fba505e4 --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/test_mentorship_sync_module_issues.py @@ -0,0 +1,223 @@ +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime +import datetime as dt +from django.utils import timezone + +from apps.mentorship.management.commands.mentorship_sync_module_issues import Command +from apps.mentorship.models.task import Task +from github.GithubException import GithubException + + +def make_qs(iterable, exists=True): + """ + Create a MagicMock that mimics a Django QuerySet for use in tests. + + Parameters: + iterable (iterable): Items that the fake queryset will iterate over and return from `.all()`. + exists (bool): Value that `.exists()` will return. + + Returns: + MagicMock: A mock object configured with `__iter__`, `all()`, and `exists()` to behave like a QuerySet. + """ + qs = MagicMock(name="QuerySet") + qs.exists.return_value = exists + qs.__iter__.return_value = iter(iterable) + qs.all.return_value = list(iterable) + return qs + + +@pytest.fixture +def command(): + """ + Create a Command instance configured for testing with mocked output and styling. + + Returns: + cmd (Command): A Command object whose `stdout` and `stderr` are MagicMock instances and whose `style` is mocked with `WARNING`, `SUCCESS`, and `ERROR` functions that return their input unchanged. + """ + 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.timezone.utc)) + e2 = MagicMock(event="assigned", assignee=MagicMock(login="other"), created_at=datetime(2023, 1, 2, tzinfo=dt.timezone.utc)) + e3 = MagicMock(event="assigned", assignee=MagicMock(login="target"), created_at=datetime(2023, 1, 3, tzinfo=dt.timezone.utc)) + e4 = MagicMock(event="assigned", assignee=MagicMock(login="target"), created_at=datetime(2023, 1, 5, tzinfo=dt.timezone.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.timezone.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], exists=True) + mock_issue.objects.filter.return_value.select_related.return_value.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 \ No newline at end of file diff --git a/backend/tests/apps/mentorship/management/commands/test_mentorship_update_comments.py b/backend/tests/apps/mentorship/management/commands/test_mentorship_update_comments.py new file mode 100644 index 0000000000..9e2d6230a7 --- /dev/null +++ b/backend/tests/apps/mentorship/management/commands/test_mentorship_update_comments.py @@ -0,0 +1,315 @@ +import pytest +from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from datetime import datetime +import datetime as dt + +from apps.mentorship.management.commands.mentorship_update_comments import Command, INTEREST_PATTERNS + + +def make_qs(iterable, exists=True): + """ + Create a MagicMock that mimics a Django QuerySet for testing. + + Parameters: + iterable (iterable): Items to populate the queryset iteration and .all() result. + exists (bool): Value returned by the queryset's .exists() method. + + Returns: + MagicMock: A mock object supporting iteration, .exists(), .count(), .distinct(), and .all() (which returns a list of the provided items). + """ + qs = MagicMock(name="QuerySet") + items = list(iterable) + qs.exists.return_value = exists + 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): + """ + Create a MagicMock representing a user with `id` and `login` attributes set. + + Parameters: + user_id: Value to assign to the mock's `id` attribute. + login: Value to assign to the mock's `login` attribute. + + Returns: + MagicMock: Mock user with `id` and `login` attributes set to the provided values. + """ + user = MagicMock() + user.id = user_id + user.login = login + return user + + +def make_comment(body, author, created_at=None): + """ + Create a MagicMock representing a comment with minimal attributes used in tests. + + Parameters: + body (str): The comment text. + author (MagicMock): The comment author object (typically a mocked user). + created_at (str|None): Value for the `nest_created_at` attribute; if None, defaults to "now". + + Returns: + MagicMock: A mock comment with `body`, `author`, and `nest_created_at` attributes set. + """ + c = MagicMock() + c.body = body + c.author = author + c.nest_created_at = created_at or "now" + return c + + +@pytest.fixture +def command(): + """ + Create a Command instance configured for tests with mocked output and simplified style helpers. + + The returned Command has `stdout` replaced by a MagicMock and `style` replaced by a MagicMock whose + WARNING, SUCCESS, and ERROR attributes are identity callables to make assertion of styled messages straightforward. + + Returns: + Command: A Command instance with `stdout` set to a MagicMock and `style.WARNING`, `style.SUCCESS`, + and `style.ERROR` set to identity lambdas. + """ + 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(): + """ + Create a MagicMock representing a module named "Test Module" whose project.repositories.filter().values_list().distinct() returns an iterable of repository IDs [1, 2]. + + The returned mock's repository queryset also reports exists() as True (and supports iteration, count(), and .all()), matching expected behavior for tests that inspect published module repositories. + + Returns: + MagicMock: A mock module with attribute `name` set to "Test Module" and a prepared repository queryset chain. + """ + m = MagicMock() + m.name = "Test Module" + vlist_qs = make_qs([1, 2], exists=True) + m.project.repositories.filter.return_value.values_list.return_value.distinct.return_value = vlist_qs + return m + + +@pytest.fixture +def mock_issue(): + """ + Create a MagicMock representing an Issue with a preset number, title, and an empty comments queryset. + + The mock's comments queryset chain (.select_related().filter().order_by()) is configured to return an iterable queryset whose exists() is False. + + Returns: + issue (MagicMock): Mock Issue with number=123, title="Test Issue Title", and an empty comments queryset. + """ + issue = MagicMock() + issue.number = 123 + issue.title = "Test Issue Title" + empty_comments_qs = make_qs([], exists=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_module.published_modules.all.return_value.exclude.return_value.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], exists=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], exists=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], exists=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 **kwargs: SimpleNamespace(**kwargs) + 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], exists=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 **kwargs: SimpleNamespace(**kwargs) + 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], exists=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 **kwargs: SimpleNamespace(**kwargs) + 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], exists=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 **kwargs: SimpleNamespace(**kwargs) + 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], + exists=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 **kwargs: SimpleNamespace(**kwargs) + 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") + ) \ No newline at end of file diff --git a/backend/tests/apps/mentorship/model/test_module.py b/backend/tests/apps/mentorship/model/test_module.py new file mode 100644 index 0000000000..1d6cc72b82 --- /dev/null +++ b/backend/tests/apps/mentorship/model/test_module.py @@ -0,0 +1,94 @@ +from unittest.mock import MagicMock, patch +from datetime import datetime +import pytest +import django.utils.timezone + +from apps.mentorship.models import Module +from apps.mentorship.models import Program +from apps.owasp.models import Project + + +class TestModulePureMocks: + def setup_method(self): + """ + Prepare test fixtures by creating MagicMock instances for Program and Project used across tests. + + The created mocks: + - self.program: a MagicMock with Program spec and preset attributes (name, key, status, started_at, ended_at) using UTC datetimes. + - self.project: a MagicMock with Project spec and preset attributes (name, key). + """ + 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): + """ + We cannot call the real Model.save without DB/app registry here, + so simulate the save behavior by attaching a side_effect to a mock instance's save method. + This verifies the *expected* effects (key slugification and date inheritance) in a pure-mock way. + """ + 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(*args, **kwargs): + """ + Configure the test mock to mimic save-time key generation and date inheritance. + + Sets mock_module.key to "date-module". If mock_module.started_at or mock_module.ended_at are falsy, assigns them from mock_module.program.started_at and mock_module.program.ended_at respectively. This function mutates the enclosing mock_module and does not return a value. + """ + 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" \ No newline at end of file