From d50315e52b472784ba7b5dd64be2f3dcb8eb4f6a Mon Sep 17 00:00:00 2001 From: Arslan Ashraf Date: Thu, 14 May 2026 15:52:04 +0500 Subject: [PATCH] test: Add tests for ol_openedx_canvas_integration --- .../tests/__init__.py | 0 .../tests/test_api.py | 271 +++++++++ .../tests/test_client.py | 341 +++++++++++ .../test_cms_tasks.py | 22 +- .../tests/test_context_api.py | 88 +++ .../tests/test_handlers.py | 86 +++ .../tests/test_receivers.py | 33 ++ .../test_settings.py | 2 +- .../tests/test_task_helpers.py | 225 +++++++ .../tests/test_tasks.py | 326 ++++++++++ .../tests/test_utils.py | 102 ++++ .../tests/test_views.py | 559 ++++++++++++++++++ 12 files changed, 2045 insertions(+), 10 deletions(-) create mode 100644 src/ol_openedx_canvas_integration/tests/__init__.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_api.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_client.py rename src/ol_openedx_canvas_integration/{ol_openedx_canvas_integration => tests}/test_cms_tasks.py (78%) create mode 100644 src/ol_openedx_canvas_integration/tests/test_context_api.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_handlers.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_receivers.py rename src/ol_openedx_canvas_integration/{ol_openedx_canvas_integration => tests}/test_settings.py (94%) create mode 100644 src/ol_openedx_canvas_integration/tests/test_task_helpers.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_tasks.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_utils.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_views.py diff --git a/src/ol_openedx_canvas_integration/tests/__init__.py b/src/ol_openedx_canvas_integration/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/tests/test_api.py b/src/ol_openedx_canvas_integration/tests/test_api.py new file mode 100644 index 000000000..e93068fa5 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_api.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import re +from types import SimpleNamespace + +import pytest +from ol_openedx_canvas_integration.constants import COURSE_KEY_ID_EMPTY + +from ol_openedx_canvas_integration import api + + +class MockSubsection: + """Minimal subsection object used for Canvas assignment payload tests.""" + + def __init__(self, location: str, display_name: str = "Mock subsection") -> None: + """Initialize a minimal subsection-like object used by tests.""" + self.location = location + self.display_name = display_name + self.fields: dict[str, str] = {} + + +class HashableUser: + """Hashable user stub that can be used as a dictionary key.""" + + def __init__(self, email: str) -> None: + """Store an email on a hashable user-like object for dict-key usage.""" + self.email = email + + def __hash__(self) -> int: + """Hash by email so equivalent email users compare and hash the same.""" + return hash(self.email) + + def __eq__(self, other) -> bool: + """Return True when another HashableUser has the same email.""" + return isinstance(other, HashableUser) and self.email == other.email + + +class StubCanvasClient: + """Canvas client stub that records assignment and grade API interactions.""" + + def __init__(self, canvas_course_id): + """Initialize stub state for Canvas API method assertions.""" + self.canvas_course_id = canvas_course_id + self.created_assignments = [] + self.updated_assignment_grades = [] + + def get_canvas_assignments(self): + """Return one pre-existing assignment keyed by subsection location.""" + return { + "block-v1:MITx+course+type@sequential+block@existing": { + "id": 101, + "is_published": False, + } + } + + def list_canvas_enrollments(self): + """Return a single enrolled learner mapping of email to Canvas id.""" + return {"learner@example.com": 42} + + def create_canvas_assignment(self, payload): + """Record and return the assignment creation payload.""" + self.created_assignments.append(payload) + return {"status": "created", "payload": payload} + + def update_assignment_grades(self, canvas_assignment_id, payload): + """Record and return an assignment grade update payload.""" + self.updated_assignment_grades.append((canvas_assignment_id, payload)) + return { + "status": "updated", + "canvas_assignment_id": canvas_assignment_id, + "payload": payload, + } + + +def _stub_canvas_client_factory(stub_client): + def _factory(**_kwargs): + return stub_client + + return _factory + + +def test_get_enrolled_non_staff_users_filters_out_staff(monkeypatch): + """Test that only enrolled users without staff access are returned.""" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + learner = SimpleNamespace(email="learner@example.com") + staff = SimpleNamespace(email="staff@example.com") + ta = SimpleNamespace(email="ta@example.com") + users = [learner, staff, ta] + + monkeypatch.setattr( + api.CourseEnrollment.objects, + "users_enrolled_in", + lambda _course_id: users, + ) + monkeypatch.setattr( + api, + "has_access", + lambda user, _role, _course: user.email in {"staff@example.com"}, + ) + + result = api.get_enrolled_non_staff_users(course) + + assert result == [learner, ta] + + +def test_enroll_emails_in_course_creates_new_users_and_enrolls_existing( + monkeypatch, +): + """Test that new users get enrollment allowed and existing users are enrolled.""" + course_key = "course-v1:MITx+Demo+2026" + existing_user = SimpleNamespace(email="existing@example.com") + enrolled_user = SimpleNamespace(email="enrolled@example.com") + nonexistent_email = "new@example.com" + + created_allowed = False + + def mock_user_filter(email=None, **_kwargs): + if email == existing_user.email: + return SimpleNamespace(first=lambda: existing_user) + elif email == enrolled_user.email: + return SimpleNamespace(first=lambda: enrolled_user) + else: + return SimpleNamespace(first=lambda: None) + + def mock_get_or_create(**kwargs): + nonlocal created_allowed + email = kwargs.get("email") + if email == nonexistent_email: + created_allowed = True + return (SimpleNamespace(email=email), True) + return (SimpleNamespace(email=email), False) + + def mock_is_enrolled(user, _course_key): + return user.email == enrolled_user.email + + enrollment_calls = [] + + def mock_enroll(user, _course_key): + enrollment_calls.append(user.email) + + monkeypatch.setattr(api.User.objects, "filter", mock_user_filter) + monkeypatch.setattr( + api.CourseEnrollmentAllowed.objects, + "get_or_create", + mock_get_or_create, + ) + monkeypatch.setattr(api.CourseEnrollment, "is_enrolled", mock_is_enrolled) + monkeypatch.setattr(api.CourseEnrollment, "enroll", mock_enroll) + + result = api.enroll_emails_in_course( + [existing_user.email, enrolled_user.email, nonexistent_email], + course_key, + ) + + assert result[existing_user.email] == "Enrolled user in the course" + assert result[enrolled_user.email] == "User already enrolled" + assert ( + result[nonexistent_email] + == "User does not exist - created course enrollment permission" + ) + assert enrollment_calls == [existing_user.email] + + +def test_get_subsection_block_user_grades_maps_locator_to_block(monkeypatch): + """Test that get subsection block user grades maps locator to block.""" + course = object() + block_1 = MockSubsection("block-1") + block_2 = MockSubsection("block-2") + + monkeypatch.setattr( + api, + "get_subsection_user_grades", + lambda _course: { + "block-1": {"student-a": "grade-a"}, + "missing-block": {"student-b": "grade-b"}, + }, + ) + monkeypatch.setattr( + api, + "course_graded_items", + lambda _course: [ + ("Homework", {"subsection_block": block_1}, 1), + ("Exam", {"subsection_block": block_2}, 2), + ], + ) + + result = api.get_subsection_block_user_grades(course) + + assert result == {block_1: {"student-a": "grade-a"}} + + +def test_push_edx_grades_to_canvas_pushes_existing_and_creates_new_assignments( + monkeypatch, +): + """Test that existing grades are pushed and missing assignments are created.""" + course = SimpleNamespace(id="course-v1:MITx+course+2026") + existing_block = MockSubsection( + "block-v1:MITx+course+type@sequential+block@existing", "Existing assignment" + ) + new_block = MockSubsection( + "block-v1:MITx+course+type@sequential+block@new", "New assignment" + ) + + existing_grade = SimpleNamespace(percent_graded=0.84) + grade_for_user_not_in_canvas = SimpleNamespace(percent_graded=0.55) + + stub_client = StubCanvasClient(canvas_course_id=2001) + + monkeypatch.setattr(api, "get_canvas_course_id", lambda _course: 2001) + monkeypatch.setattr(api, "CanvasClient", _stub_canvas_client_factory(stub_client)) + monkeypatch.setattr( + api, + "get_subsection_block_user_grades", + lambda _course: { + existing_block: { + HashableUser("learner@example.com"): existing_grade, + HashableUser("missing@example.com"): grade_for_user_not_in_canvas, + }, + new_block: { + HashableUser("learner@example.com"): existing_grade, + }, + }, + ) + + assignment_grades_updated, created_assignments = api.push_edx_grades_to_canvas( + course + ) + + assert len(stub_client.created_assignments) == 1 + assert stub_client.created_assignments[0] == api.create_assignment_payload( + new_block + ) + + assert stub_client.updated_assignment_grades == [ + (101, {"grade_data[42][posted_grade]": "84.0%"}) + ] + + assert assignment_grades_updated == { + existing_block: { + "status": "updated", + "canvas_assignment_id": 101, + "payload": {"grade_data[42][posted_grade]": "84.0%"}, + } + } + assert created_assignments == { + new_block: { + "status": "created", + "payload": api.create_assignment_payload(new_block), + } + } + + +@pytest.mark.parametrize( + ("course", "canvas_course_id", "error_message"), + [ + (None, None, COURSE_KEY_ID_EMPTY), + ( + SimpleNamespace(id="course-v1:MITx+course+2026"), + None, + "No canvas_course_id set for course: course-v1:MITx+course+2026", + ), + ], +) +def test_push_edx_grades_to_canvas_raises_for_missing_inputs( + monkeypatch, course, canvas_course_id, error_message +): + """Test that push edx grades to canvas raises for missing inputs.""" + monkeypatch.setattr(api, "get_canvas_course_id", lambda _course: canvas_course_id) + + with pytest.raises(Exception, match=re.escape(error_message)): + api.push_edx_grades_to_canvas(course) diff --git a/src/ol_openedx_canvas_integration/tests/test_client.py b/src/ol_openedx_canvas_integration/tests/test_client.py new file mode 100644 index 000000000..115cfcaa6 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_client.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +from ol_openedx_canvas_integration.constants import DEFAULT_ASSIGNMENT_POINTS + +from ol_openedx_canvas_integration import client + + +class StubSession: + """Requests session stub that records outbound Canvas API calls.""" + + def __init__(self): + """Initialize header storage and HTTP call capture lists.""" + self.headers = {} + self.post_calls = [] + self.put_calls = [] + self.delete_calls = [] + + def post(self, **kwargs): + """Record POST request arguments and return a stub response payload.""" + self.post_calls.append(kwargs) + return {"ok": True, "kwargs": kwargs} + + def put(self, **kwargs): + """Record PUT request arguments and return a stub response payload.""" + self.put_calls.append(kwargs) + return {"ok": True, "kwargs": kwargs} + + def delete(self, **kwargs): + """Record DELETE request arguments and return a stub response payload.""" + self.delete_calls.append(kwargs) + return {"ok": True, "kwargs": kwargs} + + +class StubResponse: + """HTTP response stub for pagination tests.""" + + def __init__(self, payload, link_header): + """Initialize JSON payload and Link header values.""" + self._payload = payload + self.headers = {"link": link_header} + + def raise_for_status(self): + """Mimic successful responses by raising nothing.""" + + def json(self): + """Return the configured response payload.""" + return self._payload + + +class StubCache: + """Cache stub that captures get and set operations.""" + + def __init__(self, get_value=None): + """Initialize cache hit value and call capture lists.""" + self.get_value = get_value + self.get_calls = [] + self.set_calls = [] + + def get(self, key): + """Record cache get lookups and return the configured value.""" + self.get_calls.append(key) + return self.get_value + + def set(self, key, value): + """Record cache set operations.""" + self.set_calls.append((key, value)) + + +class MockSubsection: + """Minimal subsection object used for assignment payload generation tests.""" + + def __init__(self, location, display_name, due=None): + """Initialize subsection location, name, and optional due date.""" + self.location = location + self.display_name = display_name + self.fields = {"due": due} if due else {} + + +def _settings(): + return SimpleNamespace( + CANVAS_BASE_URL="https://canvas.example.edu", + CANVAS_ACCESS_TOKEN="test-token", # noqa: S106 + ) + + +def test_get_canvas_session_sets_authorization_header(monkeypatch): + """Test that get canvas session sets authorization header.""" + session = StubSession() + + monkeypatch.setattr(client, "settings", _settings()) + monkeypatch.setattr(client.requests, "Session", lambda: session) + + result = client.CanvasClient.get_canvas_session() + + assert result is session + assert result.headers["Authorization"] == "Bearer test-token" + + +def test_list_canvas_assignments_collects_items_from_all_pages(monkeypatch): + """Test that list canvas assignments collects items from all pages.""" + session = StubSession() + page_1 = StubResponse( + payload=[{"id": 1}], + link_header='; rel="next"', + ) + page_2 = StubResponse(payload=[{"id": 2}], link_header="") + + responses = { + "https://canvas.example.edu/api/v1/courses/7/assignments?per_page=100": page_1, + "https://canvas.example.edu/page-2": page_2, + } + get_calls = [] + + def _get(url, *args, **kwargs): + get_calls.append((url, args, kwargs)) + return responses[url] + + session.get = _get + + monkeypatch.setattr( + client.CanvasClient, + "get_canvas_session", + staticmethod(lambda: session), + ) + monkeypatch.setattr(client, "settings", _settings()) + + canvas_client = client.CanvasClient(canvas_course_id=7) + items = canvas_client.list_canvas_assignments() + + assert items == [{"id": 1}, {"id": 2}] + assert [call[0] for call in get_calls] == [ + ("https://canvas.example.edu/api/v1/courses/7/assignments?per_page=100"), + "https://canvas.example.edu/page-2", + ] + + +def test_list_canvas_enrollments_returns_lowercase_email_map(monkeypatch): + """Test that list canvas enrollments returns lowercase email map.""" + monkeypatch.setattr(client, "settings", _settings()) + monkeypatch.setattr( + client.CanvasClient, + "get_canvas_session", + staticmethod(StubSession), + ) + + canvas_client = client.CanvasClient(canvas_course_id=8) + monkeypatch.setattr( + canvas_client, + "_paginate", + lambda _url: [ + {"user": {"login_id": "Learner@Example.com", "id": 11}}, + {"user": {"login_id": "another@example.com", "id": 12}}, + ], + ) + + assert canvas_client.list_canvas_enrollments() == { + "learner@example.com": 11, + "another@example.com": 12, + } + + +@pytest.mark.parametrize( + ("cache_value", "paginate_users", "expected_id", "expected_set_calls"), + [ + pytest.param( + 200, + [], + 200, + [], + id="returns_cached_id_without_fetching", + ), + pytest.param( + None, + [ + {"id": 301, "login_id": "LEARNER@example.com"}, + {"id": 302, "login_id": "other@example.com"}, + ], + 301, + [("canvas-id-learner@example.com", 301)], + id="fetches_and_caches_on_match", + ), + pytest.param( + None, + [{"id": 302, "login_id": "other@example.com"}], + None, + [], + id="returns_none_when_not_found", + ), + ], +) +def test_get_student_id_by_email( + monkeypatch, cache_value, paginate_users, expected_id, expected_set_calls +): + """Test that the student id is returned from cache, matched from Canvas, or None.""" + stub_cache = StubCache(get_value=cache_value) + + monkeypatch.setattr(client, "cache", stub_cache) + monkeypatch.setattr(client, "settings", _settings()) + monkeypatch.setattr( + client.CanvasClient, + "get_canvas_session", + staticmethod(StubSession), + ) + + canvas_client = client.CanvasClient(canvas_course_id=9) + paginate_called = [] + monkeypatch.setattr( + canvas_client, + "_paginate", + lambda _url, **_kwargs: paginate_called.append(True) or paginate_users, + ) + + student_id = canvas_client.get_student_id_by_email("learner@example.com") + + assert student_id == expected_id + assert stub_cache.get_calls == ["canvas-id-learner@example.com"] + assert stub_cache.set_calls == expected_set_calls + # Cache hit should skip the Canvas API call entirely + assert bool(paginate_called) == (cache_value is None) + + +def test_get_canvas_assignments_filters_by_integration_id_and_logs_warning( + monkeypatch, caplog +): + """Test that get canvas assignments filters by integration id and logs warning.""" + monkeypatch.setattr(client, "settings", _settings()) + monkeypatch.setattr( + client.CanvasClient, + "get_canvas_session", + staticmethod(StubSession), + ) + + canvas_client = client.CanvasClient(canvas_course_id=10) + monkeypatch.setattr( + canvas_client, + "list_canvas_assignments", + lambda: [ + {"id": 1, "integration_id": "block-1", "published": True}, + {"id": 2, "integration_id": None, "published": False}, + {"id": 3, "integration_id": "block-3"}, + {"id": 4, "integration_id": None, "published": True}, + ], + ) + + with caplog.at_level("WARNING"): + result = canvas_client.get_canvas_assignments() + + assert result == { + "block-1": {"id": 1, "is_published": True}, + "block-3": {"id": 3, "is_published": False}, + } + assert "missing an integration_id: 2, 4" in caplog.text + + +def test_assignment_mutation_methods_call_expected_canvas_endpoints(monkeypatch): + """Test that assignment mutation methods call expected canvas endpoints.""" + session = StubSession() + + monkeypatch.setattr(client, "settings", _settings()) + monkeypatch.setattr( + client.CanvasClient, + "get_canvas_session", + staticmethod(lambda: session), + ) + + canvas_client = client.CanvasClient(canvas_course_id=11) + + create_payload = {"assignment": {"name": "Quiz 1"}} + update_payload = {"assignment": {"name": "Quiz 1 (updated)"}} + grades_payload = {"grade_data[7][posted_grade]": "92.0%"} + + canvas_client.create_canvas_assignment(create_payload) + canvas_client.update_canvas_assignment(77, update_payload) + canvas_client.delete_canvas_assignment(77) + canvas_client.update_assignment_grades(77, grades_payload) + + assert session.post_calls[0]["url"] == ( + "https://canvas.example.edu/api/v1/courses/11/assignments" + ) + assert session.post_calls[0]["json"] == create_payload + + assert session.put_calls[0]["url"] == ( + "https://canvas.example.edu/api/v1/courses/11/assignments/77" + ) + assert session.put_calls[0]["json"] == update_payload + + assert session.delete_calls[0]["url"] == ( + "https://canvas.example.edu/api/v1/courses/11/assignments/77" + ) + + assert session.post_calls[1]["url"] == ( + "https://canvas.example.edu/api/v1/courses/11/assignments/77/submissions/update_grades" + ) + assert session.post_calls[1]["data"] == grades_payload + + +def test_create_assignment_payload_without_due_date(): + """Test that create assignment payload without due date.""" + subsection = MockSubsection( + "block-v1:MITx+course+type@sequential+block@sec1", "HW 1" + ) + + payload = client.create_assignment_payload(subsection) + + assert payload == { + "assignment": { + "name": "HW 1", + "integration_id": "block-v1:MITx+course+type@sequential+block@sec1", + "grading_type": "percent", + "points_possible": DEFAULT_ASSIGNMENT_POINTS, + "due_at": None, + "submission_types": ["none"], + "published": False, + } + } + + +def test_create_assignment_payload_converts_due_date_to_utc_isoformat(): + """Test that create assignment payload converts due date to utc isoformat.""" + due_date = datetime(2026, 5, 7, 12, 34, tzinfo=UTC) + subsection = MockSubsection("block-2", "Exam", due=due_date) + + payload = client.create_assignment_payload(subsection) + + assert payload["assignment"]["due_at"] == "2026-05-07T12:34:00+00:00" + + +@pytest.mark.parametrize( + ("user_id", "grade_percent", "expected"), + [ + (17, 0.92, ("grade_data[17][posted_grade]", "92.0%")), + (55, 1.0, ("grade_data[55][posted_grade]", "100.0%")), + ], +) +def test_update_grade_payload_kv(user_id, grade_percent, expected): + """Test that update grade payload kv.""" + assert client.update_grade_payload_kv(user_id, grade_percent) == expected diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py similarity index 78% rename from src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py rename to src/ol_openedx_canvas_integration/tests/test_cms_tasks.py index bc2cc94a5..cd30f3c98 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -1,19 +1,22 @@ from __future__ import annotations import pytest - from ol_openedx_canvas_integration.api import create_assignment_payload from ol_openedx_canvas_integration.cms_tasks import diff_assignments class MockSubsection: + """Subsection stub that exposes a precomputed Canvas payload helper.""" + def __init__(self, location) -> None: + """Initialize subsection identity and display metadata.""" self.location = location self.display_name = "Mock Assignment in " + str(location) self.fields: dict[str, str] = {} @property def payload(self): + """Return the Canvas assignment payload for this subsection.""" return create_assignment_payload(self) @@ -39,8 +42,8 @@ def payload(self): ( subsection_mocks[8:], { - "id-8": 1008, - "id-9": 1009, + "id-8": {"id": 1008}, + "id-9": {"id": 1009}, }, { "add": [], @@ -55,8 +58,8 @@ def payload(self): ( [], { - "synced-1": 1002, - "synced-2": 1003, + "synced-1": {"id": 1002}, + "synced-2": {"id": 1003}, }, {"add": [], "update": {}, "delete": [1002, 1003]}, ), @@ -64,10 +67,10 @@ def payload(self): ( subsection_mocks[4:8], { - "id-2": 12, # remove - "id-3": 13, # remove - "id-4": 14, # update - "id-5": 15, # update + "id-2": {"id": 12}, # remove + "id-3": {"id": 13}, # remove + "id-4": {"id": 14}, # update + "id-5": {"id": 15}, # update }, { "add": [s.payload for s in subsection_mocks[6:8]], @@ -81,6 +84,7 @@ def payload(self): ], ) def test_diff_assignments(openedx_assignments, canvas_assignments_map, expected_output): + """Test that diff assignments.""" assert ( diff_assignments(openedx_assignments, canvas_assignments_map) == expected_output ) diff --git a/src/ol_openedx_canvas_integration/tests/test_context_api.py b/src/ol_openedx_canvas_integration/tests/test_context_api.py new file mode 100644 index 000000000..898dfae87 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_context_api.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ol_openedx_canvas_integration import context_api + + +class StubFragment: + """Minimal Fragment-like stub for plugin context tests.""" + + def __init__(self): + """Initialize storage for injected JavaScript snippets.""" + self.javascript = [] + + def add_javascript(self, source): + """Record added JavaScript content.""" + self.javascript.append(source) + + +def test_get_resource_bytes_decodes_utf8(monkeypatch): + """Test that get resource bytes decodes utf8.""" + monkeypatch.setattr( + context_api.pkg_resources, + "resource_string", + lambda _module_name, _path: b"hello-canvas", + ) + + assert ( + context_api.get_resource_bytes("static/js/canvas_integration.js") + == "hello-canvas" + ) + + +def test_plugin_context_returns_none_when_no_canvas_course_id(monkeypatch): + """Test that plugin context returns none when no canvas course id.""" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + monkeypatch.setattr(context_api, "get_canvas_course_id", lambda _course: None) + + context = {"course": course, "sections": []} + + assert context_api.plugin_context(context) is None + + +def test_plugin_context_adds_canvas_section(monkeypatch): + """Test that plugin context adds canvas section.""" + course_id = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id=course_id) + + monkeypatch.setattr(context_api, "get_canvas_course_id", lambda _course: 9999) + monkeypatch.setattr(context_api, "Fragment", StubFragment) + monkeypatch.setattr( + context_api, + "get_resource_bytes", + lambda _path: "console.log('canvas');", + ) + monkeypatch.setattr( + context_api, + "reverse", + lambda name, kwargs: f"/{name}/{kwargs['course_id']}", + ) + + context = {"course": course, "sections": []} + result = context_api.plugin_context(context) + + assert result is context + assert len(result["sections"]) == 1 + + section = result["sections"][0] + assert section["section_key"] == "canvas_integration" + assert section["section_display_name"] == "Canvas" + assert section["course"] is course + assert section["add_canvas_enrollments_url"] == ( + f"/add_canvas_enrollments/{course_id}" + ) + assert section["list_canvas_enrollments_url"] == ( + f"/list_canvas_enrollments/{course_id}" + ) + assert section["list_canvas_assignments_url"] == ( + f"/list_canvas_assignments/{course_id}" + ) + assert section["list_canvas_grades_url"] == f"/list_canvas_grades/{course_id}" + assert section["list_instructor_tasks_url"] == ( + f"/list_instructor_tasks/{course_id}?include_canvas=true" + ) + assert section["push_edx_grades_url"] == f"/push_edx_grades/{course_id}" + assert section["template_path_prefix"] == "/" + assert section["fragment"].javascript == ["console.log('canvas');"] diff --git a/src/ol_openedx_canvas_integration/tests/test_handlers.py b/src/ol_openedx_canvas_integration/tests/test_handlers.py new file mode 100644 index 000000000..a6db5f573 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_handlers.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from ol_openedx_canvas_integration import handlers + + +class StubSyncTask: + """Capture queued task invocations for handler tests.""" + + def __init__(self): + """Initialize storage for delay and apply_async calls.""" + self.delay_calls = [] + self.apply_async_calls = [] + + def delay(self, *args): + """Record a delayed task invocation.""" + self.delay_calls.append(args) + + def apply_async(self, **kwargs): + """Record a scheduled task invocation.""" + self.apply_async_calls.append(kwargs) + + +def test_handle_xblock_publised_event_queues_sync(monkeypatch): + """Test that handle xblock publised event queues sync.""" + course_key = "course-v1:MITx+Demo+2026" + xblock_info = SimpleNamespace( + usage_key=SimpleNamespace(course_key=course_key), + block_type="problem", + ) + stub_task = StubSyncTask() + + monkeypatch.setattr(handlers, "sync_course_assignments_with_canvas", stub_task) + + handlers.handle_xblock_publised_event( + signal="signal", sender="sender", xblock_info=xblock_info, metadata={} + ) + + assert stub_task.delay_calls == [(course_key,)] + assert stub_task.apply_async_calls == [] + + +@pytest.mark.parametrize("block_type", ["chapter", "sequential"]) +def test_handle_xblock_deleted_event_queues_delayed_sync(monkeypatch, block_type): + """Test that handle xblock deleted event queues delayed sync.""" + course_key = "course-v1:MITx+Demo+2026" + xblock_info = SimpleNamespace( + usage_key=SimpleNamespace(course_key=course_key), + block_type=block_type, + ) + stub_task = StubSyncTask() + + monkeypatch.setattr(handlers, "sync_course_assignments_with_canvas", stub_task) + + handlers.handle_xblock_deleted_event( + signal="signal", sender="sender", xblock_info=xblock_info, metadata={} + ) + + assert stub_task.delay_calls == [] + assert stub_task.apply_async_calls == [ + { + "args": [course_key], + "countdown": 10, + } + ] + + +def test_handle_xblock_deleted_event_skips_non_assignment_blocks(monkeypatch): + """Test that handle xblock deleted event skips non assignment blocks.""" + xblock_info = SimpleNamespace( + usage_key=SimpleNamespace(course_key="course-v1:MITx+Demo+2026"), + block_type="problem", + ) + stub_task = StubSyncTask() + + monkeypatch.setattr(handlers, "sync_course_assignments_with_canvas", stub_task) + + handlers.handle_xblock_deleted_event( + signal="signal", sender="sender", xblock_info=xblock_info, metadata={} + ) + + assert stub_task.delay_calls == [] + assert stub_task.apply_async_calls == [] diff --git a/src/ol_openedx_canvas_integration/tests/test_receivers.py b/src/ol_openedx_canvas_integration/tests/test_receivers.py new file mode 100644 index 000000000..34e56af62 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_receivers.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ol_openedx_canvas_integration import receivers + + +class StubSyncUserGradeTask: + """Task stub that records delay invocations from receivers.""" + + def __init__(self): + """Initialize delay call capture list.""" + self.delay_calls = [] + + def delay(self, *args): + """Record delayed task arguments.""" + self.delay_calls.append(args) + + +def test_update_grade_in_canvas_triggers_background_task(monkeypatch): + """Test that update grade in canvas triggers background task.""" + instance = SimpleNamespace(id=321) + stub_task = StubSyncUserGradeTask() + + monkeypatch.setattr(receivers, "sync_user_grade_with_canvas", stub_task) + + receivers.update_grade_in_canvas( + sender="sender", + instance=instance, + created=False, + ) + + assert stub_task.delay_calls == [(321,)] diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_settings.py b/src/ol_openedx_canvas_integration/tests/test_settings.py similarity index 94% rename from src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_settings.py rename to src/ol_openedx_canvas_integration/tests/test_settings.py index 032f15c6b..029fa0403 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_settings.py +++ b/src/ol_openedx_canvas_integration/tests/test_settings.py @@ -11,7 +11,7 @@ def root(*args): """ Get the absolute path of the given path relative to the project root. """ - return join(abspath(dirname(__file__)), *args) # noqa: PTH100, PTH120, PTH118 + return join(abspath(dirname(__file__)), "..", *args) # noqa: PTH100, PTH120, PTH118 DATABASES = { diff --git a/src/ol_openedx_canvas_integration/tests/test_task_helpers.py b/src/ol_openedx_canvas_integration/tests/test_task_helpers.py new file mode 100644 index 000000000..5c75367e2 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_task_helpers.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +from ol_openedx_canvas_integration import task_helpers + + +class FakeQuerySet(list): + """List-backed queryset stub with a small Django-like API surface.""" + + def order_by(self, field_name): + """Return items sorted by field name, supporting leading '-' for desc.""" + reverse = field_name.startswith("-") + key_name = field_name.lstrip("-") + return FakeQuerySet( + sorted(self, key=lambda item: getattr(item, key_name), reverse=reverse) + ) + + def __or__(self, other): + """Return combined queryset contents.""" + return FakeQuerySet([*self, *other]) + + def distinct(self): + """Deduplicate items by id while preserving order.""" + seen_ids = set() + distinct_items = [] + for item in self: + if item.id not in seen_ids: + seen_ids.add(item.id) + distinct_items.append(item) + return FakeQuerySet(distinct_items) + + def __getitem__(self, index): + """Return sliced results as FakeQuerySet and scalar indexes as-is.""" + result = super().__getitem__(index) + return FakeQuerySet(result) if isinstance(index, slice) else result + + +class StubTaskProgress: + """Collect task progress updates for assertions.""" + + def __init__(self, action_name, num_reports, start_time): + """Initialize task progress fields and call capture list.""" + self.action_name = action_name + self.num_reports = num_reports + self.start_time = start_time + self.update_calls = [] + + def update_task_state(self, **kwargs): + """Record and return update payloads like the production helper.""" + self.update_calls.append(kwargs) + return {"status": "updated", "kwargs": kwargs} + + +class StubApiModule: + """API module stub that records enrollment and grade sync calls.""" + + def __init__(self): + """Initialize call capture containers.""" + self.sync_enrollments_calls = [] + self.push_grades_calls = [] + + def sync_canvas_enrollments(self, course_key, canvas_course_id, unenroll_current): + """Record sync_canvas_enrollments arguments.""" + self.sync_enrollments_calls.append( + { + "course_key": course_key, + "canvas_course_id": canvas_course_id, + "unenroll_current": unenroll_current, + } + ) + + def push_edx_grades_to_canvas(self, course): + """Record push_edx_grades_to_canvas calls and return canned output.""" + self.push_grades_calls.append({"course": course}) + return {"assignment-1": "grade-1"}, {"assignment-2": "grade-2"} + + +def test_sync_canvas_enrollments_calls_api_and_updates_task(monkeypatch): + """Test that sync canvas enrollments calls api and updates task.""" + stub_api = StubApiModule() + stub_progress = StubTaskProgress("sync", 1, 0) + + monkeypatch.setattr(task_helpers, "api", stub_api) + monkeypatch.setattr( + task_helpers, + "TaskProgress", + lambda _action, _num, _start: stub_progress, + ) + + task_input = { + "course_key": "course-v1:MITx+Demo+2026", + "canvas_course_id": 7777, + "unenroll_current": True, + } + + result = task_helpers.sync_canvas_enrollments( + _xmodule_instance_args={}, + _entry_id=1, + course_id="course-v1:MITx+Demo+2026", + task_input=task_input, + action_name="sync_canvas_enrollments", + ) + + assert stub_api.sync_enrollments_calls == [ + { + "course_key": "course-v1:MITx+Demo+2026", + "canvas_course_id": 7777, + "unenroll_current": True, + } + ] + assert stub_progress.update_calls == [{"extra_meta": {"step": "Done"}}] + assert result == {"status": "updated", "kwargs": {"extra_meta": {"step": "Done"}}} + + +def test_push_edx_grades_to_canvas_calls_api_and_updates_task(monkeypatch): + """Test that push edx grades to canvas calls api and updates task.""" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + stub_api = StubApiModule() + stub_progress = StubTaskProgress("push_grades", 1, 0) + + monkeypatch.setattr(task_helpers, "api", stub_api) + monkeypatch.setattr( + task_helpers, + "TaskProgress", + lambda _action, _num, _start: stub_progress, + ) + monkeypatch.setattr(task_helpers, "get_course_by_id", lambda _course_id: course) + + result = task_helpers.push_edx_grades_to_canvas( + _xmodule_instance_args={}, + _entry_id=2, + course_id="course-v1:MITx+Demo+2026", + task_input={}, + action_name="push_edx_grades_to_canvas", + ) + + assert stub_api.push_grades_calls == [{"course": course}] + assert stub_progress.update_calls == [ + { + "extra_meta": { + "step": "Done", + "results": {"grades": 1, "assignments": 1}, + } + } + ] + assert result == { + "status": "updated", + "kwargs": { + "extra_meta": { + "step": "Done", + "results": {"grades": 1, "assignments": 1}, + } + }, + } + + +def test_get_filtered_instructor_tasks_filters_by_type_and_date(monkeypatch): + """Test that get filtered instructor tasks filters by type and date.""" + course_id = "course-v1:MITx+Demo+2026" + user = SimpleNamespace(id=1) + now = datetime.now(UTC) + + running_task = SimpleNamespace(id=10, task_type="grade_download") + canvas_task_1 = SimpleNamespace( + id=20, + course_id=course_id, + task_type="sync_canvas_enrollments", + updated=now - timedelta(hours=1), + requester=user, + ) + canvas_task_2 = SimpleNamespace( + id=21, + course_id=course_id, + task_type="push_edx_grades_to_canvas", + updated=now - timedelta(days=1), + requester=user, + ) + old_task = SimpleNamespace( + id=22, + course_id=course_id, + task_type="sync_canvas_enrollments", + updated=now - timedelta(days=3), + requester=user, + ) + + running_tasks_qs = FakeQuerySet([running_task]) + all_instructor_tasks = [canvas_task_1, canvas_task_2, old_task] + + def mock_get_running_tasks(cid): + return running_tasks_qs if cid == course_id else FakeQuerySet() + + def mock_instructor_task_filter(**filters): + result = all_instructor_tasks + if "task_type__in" in filters: + result = [t for t in result if t.task_type in filters["task_type__in"]] + if "updated__lte" in filters: + result = [t for t in result if t.updated <= filters["updated__lte"]] + if "updated__gte" in filters: + result = [t for t in result if t.updated >= filters["updated__gte"]] + if "requester" in filters: + result = [t for t in result if t.requester == filters["requester"]] + return FakeQuerySet(result) + + task_types = [ + "sync_canvas_enrollments", + "push_edx_grades_to_canvas", + ] + monkeypatch.setattr(task_helpers, "CANVAS_TASK_TYPES", task_types) + monkeypatch.setattr( + task_helpers, "get_running_instructor_tasks", mock_get_running_tasks + ) + monkeypatch.setattr( + task_helpers.InstructorTask.objects, + "filter", + mock_instructor_task_filter, + ) + + result = task_helpers.get_filtered_instructor_tasks(course_id, user) + + assert canvas_task_1 in result + assert canvas_task_2 in result + assert old_task not in result + assert running_task in result diff --git a/src/ol_openedx_canvas_integration/tests/test_tasks.py b/src/ol_openedx_canvas_integration/tests/test_tasks.py new file mode 100644 index 000000000..00f85bb02 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_tasks.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ol_openedx_canvas_integration import tasks + + +class StubSubmitTask: + """Callable submit_task stub that records invocation arguments.""" + + def __init__(self): + """Initialize submit call capture list.""" + self.calls = [] + + def __call__(self, *args, **_kwargs): + """Record positional submit_task arguments by semantic key.""" + self.calls.append( + { + "request": args[0], + "task_type": args[1], + "task_class": args[2], + "course_id": args[3], + "task_input": args[4], + "task_key": args[5], + } + ) + return {"task_id": "test-task-id"} + + +class HashableUser: + """Minimal user stub with stable hashing for dict key usage.""" + + def __init__(self, user_id, email): + """Initialize id and email fields used by task logic.""" + self.id = user_id + self.email = email + + def __hash__(self): + """Hash by id and email for deterministic key behavior.""" + return hash((self.id, self.email)) + + def __eq__(self, other): + """Compare HashableUser instances by id and email.""" + return ( + isinstance(other, HashableUser) + and self.id == other.id + and self.email == other.email + ) + + +def _stub_canvas_client_factory(stub_client): + def _factory(**_kwargs): + return stub_client + + return _factory + + +def test_run_sync_canvas_enrollments_submits_task(monkeypatch): + """Test that run sync canvas enrollments submits task.""" + request = SimpleNamespace() + course_key = "course-v1:MITx+Demo+2026" + canvas_course_id = 9999 + unenroll_current = True + + stub_submit = StubSubmitTask() + monkeypatch.setattr(tasks, "submit_task", stub_submit) + + result = tasks.run_sync_canvas_enrollments( + request, course_key, canvas_course_id, unenroll_current + ) + + assert len(stub_submit.calls) == 1 + call = stub_submit.calls[0] + assert call["request"] is request + assert call["task_type"] == "sync_canvas_enrollments" + assert call["task_class"] == tasks.sync_canvas_enrollments_task + assert call["course_id"] == course_key + assert call["task_input"] == { + "course_key": course_key, + "canvas_course_id": canvas_course_id, + "unenroll_current": unenroll_current, + } + assert result == {"task_id": "test-task-id"} + + +def test_run_push_edx_grades_to_canvas_submits_task(monkeypatch): + """Test that run push edx grades to canvas submits task.""" + request = SimpleNamespace() + course_id = "course-v1:MITx+Demo+2026" + + stub_submit = StubSubmitTask() + monkeypatch.setattr(tasks, "submit_task", stub_submit) + + result = tasks.run_push_edx_grades_to_canvas(request, course_id) + + assert len(stub_submit.calls) == 1 + call = stub_submit.calls[0] + assert call["request"] is request + assert call["task_type"] == "push_edx_grades_to_canvas" + assert call["task_class"] == tasks.push_edx_grades_to_canvas_task + assert call["course_id"] == course_id + assert call["task_input"] == {"course_key": str(course_id)} + assert result == {"task_id": "test-task-id"} + + +def test_sync_user_grade_with_canvas_skips_when_no_canvas_course_id(monkeypatch): + """Test that sync user grade with canvas skips when no canvas course id.""" + grade_instance = SimpleNamespace( + id=1, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: None) + + result = tasks.sync_user_grade_with_canvas(1) + + assert result is None + + +def test_sync_user_grade_with_canvas_skips_when_assignment_not_synced(monkeypatch): + """Test that sync user grade with canvas skips when assignment not synced.""" + grade_instance = SimpleNamespace( + id=2, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + full_usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + stub_client = SimpleNamespace( + get_canvas_assignments=dict, + ) + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: 5555) + monkeypatch.setattr(tasks, "CanvasClient", _stub_canvas_client_factory(stub_client)) + + result = tasks.sync_user_grade_with_canvas(2) + + assert result is None + + +def test_sync_user_grade_with_canvas_skips_when_user_not_in_canvas(monkeypatch): + """Test that sync user grade with canvas skips when user not in canvas.""" + grade_instance = SimpleNamespace( + id=3, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + full_usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + stub_client = SimpleNamespace( + get_canvas_assignments=lambda: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {"id": 201} + }, + get_student_id_by_email=lambda _email: None, + ) + openedx_user = HashableUser(100, "learner@example.com") + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: 5555) + monkeypatch.setattr(tasks, "CanvasClient", _stub_canvas_client_factory(stub_client)) + monkeypatch.setattr( + tasks.USER_MODEL.objects, + "get", + lambda **_kwargs: openedx_user, + ) + + result = tasks.sync_user_grade_with_canvas(3) + + assert result is None + + +def test_sync_user_grade_with_canvas_skips_when_grade_not_found(monkeypatch): + """Test that sync user grade with canvas skips when grade not found.""" + grade_instance = SimpleNamespace( + id=4, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + full_usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + stub_client = SimpleNamespace( + get_canvas_assignments=lambda: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {"id": 201} + }, + get_student_id_by_email=lambda _email: 300, + ) + openedx_user = HashableUser(100, "learner@example.com") + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: 5555) + monkeypatch.setattr(tasks, "CanvasClient", _stub_canvas_client_factory(stub_client)) + monkeypatch.setattr( + tasks.USER_MODEL.objects, + "get", + lambda **_kwargs: openedx_user, + ) + monkeypatch.setattr( + tasks, + "get_subsection_user_grades", + lambda _course, _usage_key, _user: {}, + ) + + result = tasks.sync_user_grade_with_canvas(4) + + assert result is None + + +def test_sync_user_grade_with_canvas_success(monkeypatch): + """Test that sync user grade with canvas success.""" + grade_instance = SimpleNamespace( + id=5, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + full_usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + grade_obj = SimpleNamespace(percent_graded=0.95) + update_response = SimpleNamespace(status_code=200) + stub_client = SimpleNamespace( + get_canvas_assignments=lambda: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {"id": 201} + }, + get_student_id_by_email=lambda _email: 300, + update_assignment_grades=lambda _assign_id, _payload: update_response, + ) + openedx_user = HashableUser(100, "learner@example.com") + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: 5555) + monkeypatch.setattr(tasks, "CanvasClient", _stub_canvas_client_factory(stub_client)) + monkeypatch.setattr( + tasks.USER_MODEL.objects, + "get", + lambda **_kwargs: openedx_user, + ) + monkeypatch.setattr( + tasks, + "get_subsection_user_grades", + lambda _course, _usage_key, _user: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {openedx_user: grade_obj} + }, + ) + + result = tasks.sync_user_grade_with_canvas(5) + + assert result is None + + +def test_sync_user_grade_with_canvas_handles_api_error(monkeypatch): + """Test that sync user grade with canvas handles api error.""" + grade_instance = SimpleNamespace( + id=6, + course_id="course-v1:MITx+Demo+2026", + user_id=100, + full_usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + usage_key="block-v1:MITx+Demo+type@sequential+block@hw1", + ) + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + grade_obj = SimpleNamespace(percent_graded=0.85) + update_response = SimpleNamespace(status_code=500) + stub_client = SimpleNamespace( + get_canvas_assignments=lambda: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {"id": 201} + }, + get_student_id_by_email=lambda _email: 300, + update_assignment_grades=lambda _assign_id, _payload: update_response, + ) + openedx_user = HashableUser(100, "learner@example.com") + + monkeypatch.setattr( + tasks.PersistentSubsectionGrade.objects, + "get", + lambda **_kwargs: grade_instance, + ) + monkeypatch.setattr(tasks, "get_course_by_id", lambda _cid: course) + monkeypatch.setattr(tasks, "get_canvas_course_id", lambda _c: 5555) + monkeypatch.setattr(tasks, "CanvasClient", _stub_canvas_client_factory(stub_client)) + monkeypatch.setattr( + tasks.USER_MODEL.objects, + "get", + lambda **_kwargs: openedx_user, + ) + monkeypatch.setattr( + tasks, + "get_subsection_user_grades", + lambda _course, _usage_key, _user: { + "block-v1:MITx+Demo+type@sequential+block@hw1": {openedx_user: grade_obj} + }, + ) + + result = tasks.sync_user_grade_with_canvas(6) + + assert result is None diff --git a/src/ol_openedx_canvas_integration/tests/test_utils.py b/src/ol_openedx_canvas_integration/tests/test_utils.py new file mode 100644 index 000000000..71bc9ac09 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_utils.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from ol_openedx_canvas_integration import utils + +CANVAS_COURSE_ID_1 = 12345 +CANVAS_COURSE_ID_2 = 9999 + + +@pytest.mark.parametrize( + ("course", "expected"), + [ + pytest.param(None, None, id="course_is_none"), + pytest.param( + SimpleNamespace(other_course_settings={}), + None, + id="canvas_id_not_set", + ), + pytest.param( + SimpleNamespace(other_course_settings={"canvas_id": CANVAS_COURSE_ID_1}), + CANVAS_COURSE_ID_1, + id="canvas_id_set", + ), + pytest.param( + SimpleNamespace( + other_course_settings={ + "canvas_id": CANVAS_COURSE_ID_2, + "other_setting": "value", + "another_setting": 123, + } + ), + CANVAS_COURSE_ID_2, + id="canvas_id_with_other_settings", + ), + ], +) +def test_get_canvas_course_id(course, expected): + """Test that get_canvas_course_id returns canvas_id from course settings, or None. + + None is returned when the course is None or canvas_id is absent from settings. + """ + assert utils.get_canvas_course_id(course=course) == expected + + +@pytest.mark.parametrize( + ("task_output", "expected"), + [ + pytest.param( + {}, + "0 grades and 0 assignments updated or created", + id="no_results_key", + ), + pytest.param( + {"results": {}}, + "0 grades and 0 assignments updated or created", + id="empty_results", + ), + pytest.param( + {"results": {"assignments": 5}}, + "0 grades and 5 assignments updated or created", + id="only_assignments", + ), + pytest.param( + {"results": {"grades": 10}}, + "10 grades and 0 assignments updated or created", + id="only_grades", + ), + pytest.param( + {"results": {"grades": 15, "assignments": 3}}, + "15 grades and 3 assignments updated or created", + id="grades_and_assignments", + ), + pytest.param( + { + "results": { + "grades": 25, + "assignments": 7, + "other_field": "ignored", + "another_field": 99, + } + }, + "25 grades and 7 assignments updated or created", + id="additional_fields_ignored", + ), + pytest.param( + {"results": {"grades": 0, "assignments": 0}}, + "0 grades and 0 assignments updated or created", + id="zero_counts", + ), + pytest.param( + {"results": {"grades": 1000, "assignments": 500}}, + "1000 grades and 500 assignments updated or created", + id="high_counts", + ), + ], +) +def test_get_task_output_formatted_message(task_output, expected): + """Test that formatted message correctly reports grade and assignment counts.""" + assert utils.get_task_output_formatted_message(task_output) == expected diff --git a/src/ol_openedx_canvas_integration/tests/test_views.py b/src/ol_openedx_canvas_integration/tests/test_views.py new file mode 100644 index 000000000..054d6211c --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_views.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import re +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from ol_openedx_canvas_integration.constants import COURSE_KEY_ID_EMPTY + +from ol_openedx_canvas_integration import views + +HTTP_OK = 200 + + +def _unwrap_callable(func): + """Unwrap decorators, including wrappers that don't set __wrapped__.""" + current = func + seen = set() + + while id(current) not in seen: + seen.add(id(current)) + + wrapped = getattr(current, "__wrapped__", None) + if callable(wrapped): + current = wrapped + continue + + closure = getattr(current, "__closure__", None) or () + freevars = getattr(current, "__code__", None) + freevars = getattr(freevars, "co_freevars", ()) + + next_func = None + for name, cell in zip(freevars, closure): + try: + value = cell.cell_contents + except ValueError: + continue + if name in {"func", "view_func", "wrapped", "callback"} and callable(value): + next_func = value + break + + if callable(next_func): + current = next_func + continue + + break + + return current + + +def _call_view(view_func, request, course_id): + return _unwrap_callable(view_func)(request, course_id=course_id) + + +def _stub_canvas_client_factory(stub_client): + def _factory(**_kwargs): + return stub_client + + return _factory + + +class StubResponse: + """Simple response object stub used by view tests.""" + + def __init__(self, status_code=200, data=None): + """Initialize response fields used by assertions.""" + self.status_code = status_code + self.data = data or {} + + +@pytest.mark.parametrize( + ("has_user", "has_allowed", "is_enrolled_val", "expected"), + [ + pytest.param( + False, + False, + False, + {"exists_in_edx": False, "enrolled_in_edx": False, "allowed_in_edx": False}, + id="no_user_no_allowed", + ), + pytest.param( + True, + False, + False, + {"exists_in_edx": True, "enrolled_in_edx": False, "allowed_in_edx": False}, + id="user_exists_not_enrolled", + ), + pytest.param( + True, + False, + True, + {"exists_in_edx": True, "enrolled_in_edx": True, "allowed_in_edx": False}, + id="user_enrolled", + ), + pytest.param( + False, + True, + False, + {"exists_in_edx": False, "enrolled_in_edx": False, "allowed_in_edx": True}, + id="allowed_enrollment", + ), + ], +) +def test_get_edx_enrollment_data(has_user, has_allowed, is_enrolled_val, expected): + """Test that _get_edx_enrollment_data returns correct flags for all combos.""" + email = "test@example.com" + course_key = "course-v1:MITx+Demo+2026" + user = SimpleNamespace(id=100, email=email) if has_user else None + allowed = ( + SimpleNamespace(email=email, course_id=course_key) if has_allowed else None + ) + + original_user_objects = views.User.objects + original_allowed_objects = views.CourseEnrollmentAllowed.objects + original_is_enrolled = views.CourseEnrollment.is_enrolled + + views.User.objects = SimpleNamespace( + filter=lambda **_kwargs: SimpleNamespace(first=lambda: user) + ) + views.CourseEnrollmentAllowed.objects = SimpleNamespace( + filter=lambda **_kwargs: SimpleNamespace(first=lambda: allowed) + ) + views.CourseEnrollment.is_enrolled = lambda _u, _ck: is_enrolled_val + + try: + result = views._get_edx_enrollment_data(email, course_key) # noqa: SLF001 + assert result == expected + finally: + views.User.objects = original_user_objects + views.CourseEnrollmentAllowed.objects = original_allowed_objects + views.CourseEnrollment.is_enrolled = original_is_enrolled + + +@pytest.mark.parametrize( + "view_func", + [ + pytest.param(views.list_canvas_enrollments, id="list_canvas_enrollments"), + pytest.param(views.list_canvas_assignments, id="list_canvas_assignments"), + pytest.param(views.list_canvas_grades, id="list_canvas_grades"), + ], +) +def test_list_view_raises_when_no_course_id(view_func): + """Test that list views raise when course_id is empty, before any processing.""" + request = MagicMock() + + with pytest.raises(Exception, match=re.escape(COURSE_KEY_ID_EMPTY)): + _call_view(view_func, request, course_id="") + + +def test_list_canvas_enrollments_raises_when_no_canvas_course_id(monkeypatch): + """Test that list canvas enrollments raises when no canvas course id.""" + request = MagicMock() + course_key = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return None + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + + error_message = f"No canvas_course_id set for course: {course_key}" + with pytest.raises(Exception, match=re.escape(error_message)): + _call_view(views.list_canvas_enrollments, request, course_id=course_key) + + +def test_list_canvas_enrollments_success(monkeypatch): + """Test that list canvas enrollments success.""" + request = MagicMock() + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + enrollment_dict = { + "user1@example.com": {"id": 101}, + "user2@example.com": {"id": 102}, + } + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + def mock_user_filter(**_kwargs): + return SimpleNamespace(first=lambda: None) + + def mock_allowed_filter(**_kwargs): + return SimpleNamespace(first=lambda: None) + + stub_client = SimpleNamespace( + list_canvas_enrollments=lambda: enrollment_dict, + ) + + original_user_objects = views.User.objects + original_allowed_objects = views.CourseEnrollmentAllowed.objects + original_is_enrolled = views.CourseEnrollment.is_enrolled + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views, "CanvasClient", _stub_canvas_client_factory(stub_client)) + views.User.objects = SimpleNamespace(filter=mock_user_filter) + views.CourseEnrollmentAllowed.objects = SimpleNamespace(filter=mock_allowed_filter) + views.CourseEnrollment.is_enrolled = lambda _user, _course_key: False + + try: + response = _call_view(views.list_canvas_enrollments, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + finally: + views.User.objects = original_user_objects + views.CourseEnrollmentAllowed.objects = original_allowed_objects + views.CourseEnrollment.is_enrolled = original_is_enrolled + + +def test_list_canvas_assignments_raises_when_no_canvas_course_id(monkeypatch): + """Test that list canvas assignments raises when no canvas course id.""" + request = MagicMock() + course_key = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return None + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + + error_message = f"No canvas_course_id set for course: {course_key}" + with pytest.raises(Exception, match=re.escape(error_message)): + _call_view(views.list_canvas_assignments, request, course_id=course_key) + + +def test_list_canvas_assignments_success(monkeypatch): + """Test that list canvas assignments success.""" + request = MagicMock() + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + assignments = [ + {"id": 201, "name": "Assignment 1"}, + {"id": 202, "name": "Assignment 2"}, + ] + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + stub_client = SimpleNamespace( + list_canvas_assignments=lambda: assignments, + ) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views, "CanvasClient", _stub_canvas_client_factory(stub_client)) + + response = _call_view(views.list_canvas_assignments, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + + +def test_list_canvas_grades_raises_when_no_canvas_course_id(monkeypatch): + """Test that list canvas grades raises when no canvas course id.""" + request = MagicMock() + request.GET = {"assignment_id": "201"} + course_key = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return None + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + + error_message = f"No canvas_course_id set for course {course_key}" + with pytest.raises(Exception, match=re.escape(error_message)): + _call_view(views.list_canvas_grades, request, course_id=course_key) + + +def test_list_canvas_grades_success(monkeypatch): + """Test that list canvas grades success.""" + request = MagicMock() + request.GET = {"assignment_id": "201"} + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + grades = [ + {"user_id": 101, "score": 95}, + {"user_id": 102, "score": 87}, + ] + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + stub_client = SimpleNamespace( + list_canvas_grades=lambda **_kwargs: grades, + ) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views, "CanvasClient", _stub_canvas_client_factory(stub_client)) + + response = _call_view(views.list_canvas_grades, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + + +@pytest.mark.parametrize( + "view_func", + [ + pytest.param(views.add_canvas_enrollments, id="add_canvas_enrollments"), + pytest.param(views.push_edx_grades, id="push_edx_grades"), + ], +) +def test_view_without_course_id_guard_raises_when_no_course_id(monkeypatch, view_func): + """Test that views without an early course_id guard raise via from_string.""" + request = MagicMock() + + def mock_from_string(_course_id): + raise Exception(COURSE_KEY_ID_EMPTY) # noqa: TRY002 + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + with pytest.raises(Exception, match=re.escape(COURSE_KEY_ID_EMPTY)): + _call_view(view_func, request, course_id="") + + +def test_add_canvas_enrollments_raises_when_no_canvas_course_id(monkeypatch): + """Test that add canvas enrollments raises when no canvas course id.""" + request = MagicMock() + request.POST = {"unenroll_current": "true"} + course_key = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return None + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + + error_message = f"No canvas_course_id set for course {course_key}" + with pytest.raises(Exception, match=re.escape(error_message)): + _call_view(views.add_canvas_enrollments, request, course_id=course_key) + + +def test_add_canvas_enrollments_success(monkeypatch): + """Test that add canvas enrollments success.""" + request = MagicMock() + request.POST = {"unenroll_current": "true"} + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + sync_calls = [] + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + def mock_run_sync(**kwargs): + sync_calls.append(kwargs) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views.tasks, "run_sync_canvas_enrollments", mock_run_sync) + + response = _call_view(views.add_canvas_enrollments, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + assert len(sync_calls) == 1 + assert sync_calls[0] == { + "request": request, + "course_key": course_id, + "canvas_course_id": canvas_course_id, + "unenroll_current": True, + } + + +def test_add_canvas_enrollments_handles_already_running_error(monkeypatch): + """Test that add canvas enrollments handles already running error.""" + request = MagicMock() + request.POST = {} + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + def mock_run_sync(**_kwargs): + msg = "Task already running" + raise views.AlreadyRunningError(msg) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views.tasks, "run_sync_canvas_enrollments", mock_run_sync) + + response = _call_view(views.add_canvas_enrollments, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + + +def test_push_edx_grades_raises_when_no_canvas_course_id(monkeypatch): + """Test that push edx grades raises when no canvas course id.""" + request = MagicMock() + course_key = "course-v1:MITx+Demo+2026" + course = SimpleNamespace(id="course-v1:MITx+Demo+2026") + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return None + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + + error_message = f"No canvas_course_id set for course: {course_key}" + with pytest.raises(Exception, match=re.escape(error_message)): + _call_view(views.push_edx_grades, request, course_id=course_key) + + +def test_push_edx_grades_success(monkeypatch): + """Test that push edx grades success.""" + request = MagicMock() + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + push_calls = [] + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + def mock_run_push(**kwargs): + push_calls.append(kwargs) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views.tasks, "run_push_edx_grades_to_canvas", mock_run_push) + + response = _call_view(views.push_edx_grades, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK + assert len(push_calls) == 1 + assert push_calls[0] == {"request": request, "course_id": course_id} + + +def test_push_edx_grades_handles_already_running_error(monkeypatch): + """Test that push edx grades handles already running error.""" + request = MagicMock() + course_id = "course-v1:MITx+Demo+2026" + course_key = course_id + course = SimpleNamespace(id=course_id) + canvas_course_id = 5555 + + def mock_from_string(_course_id): + return course_key + + def mock_get_course(_course_key): + return course + + def mock_get_canvas_id(_course): + return canvas_course_id + + def mock_run_push(**_kwargs): + msg = "Task already running" + raise views.AlreadyRunningError(msg) + + monkeypatch.setattr(views.CourseLocator, "from_string", mock_from_string) + monkeypatch.setattr(views, "get_course_by_id", mock_get_course) + monkeypatch.setattr(views, "get_canvas_course_id", mock_get_canvas_id) + monkeypatch.setattr(views.tasks, "run_push_edx_grades_to_canvas", mock_run_push) + + response = _call_view(views.push_edx_grades, request, course_id) + + assert isinstance(response, views.JsonResponse) + assert response.status_code == HTTP_OK