diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f2e5ad859..eefb043f8 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -7,13 +7,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - tutor_version: ['<18.0.0', '<19.0.0'] + tutor_version: ['nightly'] steps: - name: Run Integration Tests - uses: eduNEXT/integration-test-in-tutor@mjh/run-integration-tests-outside-container + uses: eduNEXT/integration-test-in-tutor@mjh/test-changes-run-tests-outside-container with: tutor_version: ${{ matrix.tutor_version }} app_name: 'eox-core' - openedx_extra_pip_requeriments: 'eox-tenant' + openedx_extra_pip_requirements: 'eox-tenant' shell_file_to_run: 'scripts/execute_integration_tests.sh' fixtures_file: 'fixtures/initial_data.json' + openedx_imports_test_file_path: 'eox_core/edxapp_wrapper/tests/integration/test_backends.py' + openedx_imports_test_function_name: 'test_current_settings_code_imports' diff --git a/Makefile b/Makefile index aafdbd991..922ab2dc9 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ python-quality-test: run-tests: python-test python-quality-test run-integration-tests: install-dev-dependencies - pytest ./eox_core --ignore-glob='**/unit/*' --ignore-glob='**/edxapp_wrapper/*' + pytest -rPf ./eox_core --ignore-glob='**/unit/*' --ignore-glob='**/edxapp_wrapper/*' upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade upgrade: ## update the requirements/*.txt files with the latest packages satisfying requirements/*.in diff --git a/eox_core/api/v1/tests/integration/data/fake_users.py b/eox_core/api/v1/tests/integration/data/fake_users.py index 53d4d0330..1329137f1 100644 --- a/eox_core/api/v1/tests/integration/data/fake_users.py +++ b/eox_core/api/v1/tests/integration/data/fake_users.py @@ -626,5 +626,57 @@ "city": "Charleston", "goals": "Aenean vulputate eleifend tellus.", }, + { + "username": "gwalker33", + "email": "gwalker33@protonmail.com", + "fullname": "Gary Walker", + "password": "zP7%1Yt!dB@", + "activate_user": True, + "mailing_address": "321 Birch Road", + "year_of_birth": 1996, + "gender": "m", + "level_of_education": "hs", + "city": "Sacramento", + "goals": "Praesent vestibulum dapibus nibh.", + }, + { + "username": "tturner34", + "email": "tturner34@live.com", + "fullname": "Tina Turner", + "password": "wJ4@5Lm!tQ#", + "activate_user": False, + "mailing_address": "753 Pine Street", + "year_of_birth": 1988, + "gender": "f", + "level_of_education": "m", + "city": "Portland", + "goals": "Duis lobortis massa nec est.", + }, + { + "username": "jwhite35", + "email": "jwhite35@gmail.com", + "fullname": "Jack White", + "password": "qB2%9Cv!xF@", + "activate_user": True, + "mailing_address": "159 Spruce Lane", + "year_of_birth": 1995, + "gender": "m", + "level_of_education": "hs", + "city": "New Orleans", + "goals": "Vestibulum volutpat pretium libero.", + }, + { + "username": "gwalker33", + "email": "gwalker33@protonmail.com", + "fullname": "Gary Walker", + "password": "zP7%1Yt!dB@", + "activate_user": True, + "mailing_address": "321 Birch Road", + "year_of_birth": 1996, + "gender": "m", + "level_of_education": "hs", + "city": "Sacramento", + "goals": "Praesent vestibulum dapibus nibh.", + }, ] ) diff --git a/eox_core/api/v1/tests/integration/test_views.py b/eox_core/api/v1/tests/integration/test_views.py index 5a0043856..f9cf9693d 100644 --- a/eox_core/api/v1/tests/integration/test_views.py +++ b/eox_core/api/v1/tests/integration/test_views.py @@ -2,11 +2,13 @@ Integration test suite for the API v1 views. """ +# pylint: disable=too-many-lines from __future__ import annotations +from copy import deepcopy + +import ddt import requests -from ddt import data as ddt_data -from ddt import ddt, unpack from django.conf import settings as ds from django.test import TestCase from django.urls import reverse @@ -16,6 +18,75 @@ settings = ds.INTEGRATION_TEST_SETTINGS +USER_URL = f"{settings['EOX_CORE_API_BASE']}{reverse('eox-api:eox-api:edxapp-user')}" +USER_UPDATER_URL = f"{settings['EOX_CORE_API_BASE']}{reverse('eox-api:eox-api:edxapp-user-updater')}" +ENROLLMENT_URL = f"{settings['EOX_CORE_API_BASE']}{reverse('eox-api:eox-api:edxapp-enrollment')}" + + +def get_access_token() -> str: + """ + Get an access token for all requests in the test suite. + + Returns: + str: The access token. + """ + data = { + "client_id": settings["CLIENT_ID"], + "client_secret": settings["CLIENT_SECRET"], + "grant_type": "client_credentials", + } + url = f"http://{settings['LMS_BASE']}/oauth2/access_token/" + response = requests.post(url, data=data, timeout=settings["API_TIMEOUT"]) + return response.json()["access_token"] + + +ACCESS_TOKEN = get_access_token() + + +# pylint: disable=too-many-arguments +def make_request( + tenant: dict, + method: str, + url: str, + json: dict | None = None, + data: dict | None = None, + params: dict | None = None, + with_auth: bool = True, +) -> requests.Response: + """ + Make a request to a site (default site or tenant). + + Args: + tenant (dict): The tenant data. + method (str): The HTTP method ('GET', 'POST', etc.). + url (str): The URL to make the request to. + json (dict, optional): The JSON data for POST, PATCH and PUT requests. + data (dict, optional): The data for POST, PATCH and PUT requests. + params (dict, optional): The parameters for GET and DELETE requests. + with_auth (bool, optional): Whether to include the access token in the request headers. + + Returns: + requests.Response: The response object. + """ + headers = {"Host": tenant["domain"]} + if with_auth: + headers["Authorization"] = f"Bearer {ACCESS_TOKEN}" + full_url = f"{tenant['base_url']}/{url}" + + method = method.upper() + if method not in ("GET", "POST", "PATCH", "PUT", "DELETE"): + raise ValueError(f"Unsupported HTTP method: {method}.") + + return requests.request( + method, + full_url, + json=json, + data=data, + params=params, + headers=headers, + timeout=settings["API_TIMEOUT"], + ) + class BaseAPIIntegrationTest(TestCase): """ @@ -37,7 +108,7 @@ def get_tenant_data(self, prefix: str = "") -> dict: If no prefix is provided, the default site data is returned. Args: - prefix (str): The tenant prefix. + prefix (str, optional): The tenant prefix. Defaults to "". Returns: dict: The tenant data. @@ -48,122 +119,113 @@ def get_tenant_data(self, prefix: str = "") -> dict: "domain": domain, } - def get_access_token(self, tenant_base_url: str) -> str: + +class UsersAPIRequestMixin: + """ + Mixin class for the API request methods. + """ + + def create_user(self, tenant: dict, data: dict) -> requests.Response: """ - Get an access token for a tenant. + Create a new user in a tenant. Args: - tenant_base_url (str): The tenant base URL. + tenant (dict): The tenant data. + data (dict): The user data. Returns: - str: The access token. + requests.Response: The response object. """ - data = { - "client_id": settings["CLIENT_ID"], - "client_secret": settings["CLIENT_SECRET"], - "grant_type": "client_credentials", - } - url = f"{tenant_base_url}/oauth2/access_token/" - response = requests.post(url, data=data, timeout=settings["API_TIMEOUT"]) - return response.json()["access_token"] - - # pylint: disable=too-many-arguments - def make_request( - self, - tenant: dict, - method: str, - url: str, - json: dict | None = None, - data: dict | None = None, - params: dict | None = None, - with_auth: bool = True, - ) -> requests.Response: - """ - Make a request to a tenant. + return make_request(tenant, "POST", url=USER_URL, json=data) + + def get_user(self, tenant: dict, params: dict | None = None) -> requests.Response: + """ + Get a user in a tenant by username or email. Args: tenant (dict): The tenant data. - method (str): The HTTP method ('GET', 'POST', etc.). - url (str): The URL to make the request to. - json (dict, optional): The JSON data for POST, PATCH and PUT requests. - data (dict, optional): The data for POST, PATCH and PUT requests. - params (dict, optional): The parameters for GET and DELETE requests. - with_auth (bool, optional): Whether to include the access token in the request headers. + params (dict, optional): The query parameters for the request. Returns: requests.Response: The response object. """ - headers = {"Host": tenant["domain"]} - if with_auth: - access_token = self.get_access_token(tenant["base_url"]) - headers["Authorization"] = f"Bearer {access_token}" - full_url = f"{tenant['base_url']}/{url}" - - method = method.upper() - if method not in ("GET", "POST", "PATCH"): - raise ValueError(f"Unsupported HTTP method: {method}") - - return requests.request( - method, - full_url, - json=json, - data=data, - params=params, - headers=headers, - timeout=settings["API_TIMEOUT"], - ) + return make_request(tenant, "GET", url=USER_URL, params=params) + def update_user(self, tenant: dict, data: dict) -> requests.Response: + """ + Update a user in a tenant. -@ddt -class TestUsersAPIIntegration(BaseAPIIntegrationTest): - """Integration test suite for the Users API""" + Args: + tenant (dict): The tenant data. + data (dict): The user data. + + Returns: + requests.Response: The response object. + """ + return make_request(tenant, "PATCH", url=USER_UPDATER_URL, json=data) - def setUp(self): - """Set up the test suite""" - self.user_url = f"{settings['EOX_CORE_API_BASE']}{reverse('eox-api:eox-api:edxapp-user')}" - self.user_updater_url = f"{settings['EOX_CORE_API_BASE']}{reverse('eox-api:eox-api:edxapp-user-updater')}" - super().setUp() - def create_user_in_tenant(self, tenant: dict, user_data: dict) -> requests.Response: +class EnrollmentAPIRequestMixin: + """Mixin class for the API request methods.""" + + def create_enrollment(self, tenant: dict, data: dict) -> requests.Response: """ Create a new user in a tenant. Args: tenant (dict): The tenant data. - user_data (dict): The user data. + data (dict): The user data. Returns: requests.Response: The response object. """ - return self.make_request(tenant, "POST", url=self.user_url, data=user_data) + return make_request(tenant, "POST", url=ENROLLMENT_URL, data=data) - def get_user_in_tenant(self, tenant: dict, params: dict | None = None) -> requests.Response: + def get_enrollment(self, tenant: dict, data: dict | None = None) -> requests.Response: """ Get a user in a tenant by username or email. Args: tenant (dict): The tenant data. - params (dict, optional): The query parameters for the request. + data (dict, optional): The body data for the request. Returns: requests.Response: The response object. """ - return self.make_request(tenant, "GET", url=self.user_url, params=params) + return make_request(tenant, "GET", url=ENROLLMENT_URL, data=data) - def update_user_in_tenant(self, tenant: dict, user_data: dict) -> requests.Response: + def update_enrollment(self, tenant: dict, data: dict | None = None) -> requests.Response: """ - Update a user in a tenant. + Update an enrollment in a tenant. + + Args: + tenant (dict): The tenant data. + data (dict, optional): The body data for the request. + + Returns: + requests.Response: The response object. + """ + return make_request(tenant, "PUT", url=ENROLLMENT_URL, data=data) + + def delete_enrollment(self, tenant: dict, data: dict | None = None) -> requests.Response: + """ + Delete an enrollment in a tenant. Args: tenant (dict): The tenant data. - user_data (dict): The user data. + data (dict, optional): The body data for the request. Returns: requests.Response: The response object. """ - return self.make_request(tenant, "PATCH", url=self.user_updater_url, json=user_data) + return make_request(tenant, "DELETE", url=ENROLLMENT_URL, data=data) + + +@ddt.ddt +class TestUsersAPIIntegration(BaseAPIIntegrationTest, UsersAPIRequestMixin): + """Integration test suite for the Users API""" - @ddt_data( + @ddt.data( {"is_staff": False, "is_superuser": False}, {"is_staff": True, "is_superuser": False}, {"is_staff": False, "is_superuser": True}, @@ -184,7 +246,7 @@ def test_create_user_in_tenant_success(self, permissions: dict) -> None: data = next(FAKE_USER_DATA) data.update(permissions) - response = self.create_user_in_tenant(self.tenant_x, data) + response = self.create_user(self.tenant_x, data) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -210,7 +272,7 @@ def test_create_user_missing_required_fields(self) -> None: del data["email"] del data["username"] - response = self.create_user_in_tenant(self.tenant_x, data) + response = self.create_user(self.tenant_x, data) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -230,15 +292,15 @@ def test_create_user_in_tenant_user_already_exists(self) -> None: - The user is not created in the tenant. """ data = next(FAKE_USER_DATA) - self.create_user_in_tenant(self.tenant_x, data) + self.create_user(self.tenant_x, data) - response = self.create_user_in_tenant(self.tenant_x, data) + response = self.create_user(self.tenant_x, data) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("non_field_errors", response_data) - @ddt_data("username", "email") + @ddt.data("username", "email") def test_get_user_in_tenant_success(self, query_param: str) -> None: """ Test getting a user in a tenant. @@ -251,9 +313,9 @@ def test_get_user_in_tenant_success(self, query_param: str) -> None: - The response contains the user data. """ data = next(FAKE_USER_DATA) - self.create_user_in_tenant(self.tenant_x, data) + self.create_user(self.tenant_x, data) - response = self.get_user_in_tenant(self.tenant_x, {query_param: data[query_param]}) + response = self.get_user(self.tenant_x, {query_param: data[query_param]}) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -271,9 +333,9 @@ def test_get_user_of_another_tenant(self) -> None: - The response contains an error message. """ data = next(FAKE_USER_DATA) - self.create_user_in_tenant(self.tenant_x, data) + self.create_user(self.tenant_x, data) - response = self.get_user_in_tenant(self.tenant_y, {"username": data["username"]}) + response = self.get_user(self.tenant_y, {"username": data["username"]}) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -283,11 +345,11 @@ def test_get_user_of_another_tenant(self) -> None: f"No user found by {{'username': '{data['username']}'}} on site {self.tenant_y['domain']}.", ) - @ddt_data( + @ddt.data( ("username", "user-not-found"), ("email", "user-not-found@mail.com"), ) - @unpack + @ddt.unpack def test_get_user_in_tenant_user_not_found(self, param: str, value: str) -> None: """ Test getting a user in a tenant that does not exist. @@ -299,7 +361,7 @@ def test_get_user_in_tenant_user_not_found(self, param: str, value: str) -> None - The status code is 404. - The response contains an error message. """ - response = self.get_user_in_tenant(self.tenant_x, {param: value}) + response = self.get_user(self.tenant_x, {param: value}) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -324,12 +386,12 @@ def test_update_user_in_tenant_success(self) -> None: - The user is updated successfully in the tenant with the provided data. """ data = next(FAKE_USER_DATA) - self.create_user_in_tenant(self.tenant_x, data) + self.create_user(self.tenant_x, data) updated_data = next(FAKE_USER_DATA) updated_data["username"] = data["username"] updated_data["email"] = data["email"] - response = self.update_user_in_tenant(self.tenant_x, user_data=updated_data) + response = self.update_user(self.tenant_x, data=updated_data) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -343,11 +405,11 @@ def test_update_user_in_tenant_success(self) -> None: self.assertEqual(response_data["goals"], updated_data["goals"]) self.assertTrue(response_data["is_active"]) - @ddt_data( + @ddt.data( ("username", "user-not-found"), ("email", "user-not-found@mail.com"), ) - @unpack + @ddt.unpack def test_update_user_in_tenant_user_not_found(self, param: str, value: str) -> None: """ Test updating a user in a tenant that does not exist. @@ -359,7 +421,7 @@ def test_update_user_in_tenant_user_not_found(self, param: str, value: str) -> N - The status code is 404. - The response contains an error message. """ - response = self.update_user_in_tenant(self.tenant_x, {param: value}) + response = self.update_user(self.tenant_x, {param: value}) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -370,6 +432,591 @@ def test_update_user_in_tenant_user_not_found(self, param: str, value: str) -> N ) +@ddt.ddt +class TestEnrollmentAPIIntegration(BaseAPIIntegrationTest, UsersAPIRequestMixin, EnrollmentAPIRequestMixin): + """Integration test suite for the Enrollment API""" + + def setUp(self) -> None: + """Set up the test suite""" + super().setUp() + self.demo_course_id = settings["DEMO_COURSE_ID"] + self.mode = "audit" + self.user = next(FAKE_USER_DATA) + self.create_user(self.tenant_x, self.user) + + @ddt.data( + ("email", True), + ("email", False), + ("username", True), + ("username", False), + ) + @ddt.unpack + def test_create_enrollment_success(self, param: str, force_value: bool) -> None: + """ + Test creating an enrollment with a valid user, course and mode in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `create_enrollment` + - `check_edxapp_account_conflicts` + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 200. + - The response indicates the enrollment was created successfully. + - The enrollment is created in the tenant with the provided data. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + "force": force_value, + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["username"], self.user["username"]) + self.assertEqual(response_data["mode"], enrollment_data["mode"]) + self.assertEqual(response_data["course_id"], enrollment_data["course_id"]) + self.assertTrue(response_data["is_active"]) + self.assertIn("created", response_data) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + + @ddt.data( + ("mode", {"mode": ["This field is required."]}), + ("course_id", {"non_field_errors": ["You have to provide a course_id or bundle_id"]}), + ("username", {"non_field_errors": ["Email or username needed"]}), + ) + @ddt.unpack + def test_create_enrollment_missing_required_fields(self, param: str, error: list | dict) -> None: + """ + Test creating an enrollment with missing required fields. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 400. + - The response contains a message about the missing field. + - The enrollment is not created in the tenant. + """ + enrollment_data = { + "username": self.user["username"], + "mode": self.mode, + "course_id": self.demo_course_id, + } + enrollment_data_copy = deepcopy(enrollment_data) + enrollment_data.pop(param) + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response_data, error) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data_copy) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data( + ("email", "user-not-found"), + ("username", "user-not-found@mail.com"), + ) + @ddt.unpack + def test_create_valid_course_mode_invalid_user(self, param: str, value: str) -> None: + """ + Test creating an enrollment with a valid course, valid mode, and a non-existent user in a tenant. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 400. + - The response contains an error message about the user not found. + - The enrollment is not created in the tenant. + """ + enrollment_data = { + param: value, + "course_id": self.demo_course_id, + "mode": self.mode, + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response_data) + self.assertEqual(response_data["non_field_errors"], ["User not found"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_create_valid_course_mode_invalid_user_for_tenant(self, param: str) -> None: + """ + Test creating an enrollment with a valid course, valid mode, and a user from another tenant. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 202. + - The response contains an error message about the user not found on the tenant. + - The enrollment is not created in the tenant. + """ + user_data = next(FAKE_USER_DATA) + self.create_user(self.tenant_y, user_data) + enrollment_data = { + param: user_data[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual( + response_data["error"]["detail"], + f"No user found by {{'{param}': '{enrollment_data[param]}'}} on site {self.tenant_x['domain']}.", + ) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_create_valid_user_mode_invalid_course(self, param: str) -> None: + """ + Test creating an enrollment with a valid user, valid mode, and a non-existent course in a tenant. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 400. + - The response contains an error message about the course not found. + - The enrollment is not created in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": "course-v1:OpenedX+DemoX+NonExistentCourse", + "mode": self.mode, + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response_data) + self.assertEqual(response_data["non_field_errors"], ["Course not found"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_create_valid_user_mode_invalid_course_for_tenant(self, param: str) -> None: + """ + Test creating an enrollment with a valid user, valid mode, and a course from another tenant. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + - `validate_org` + + Expected result: + - The status code is 400. + - The response contains an error message about the course not found on the tenant. + - The enrollment is not created in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + + response = self.create_enrollment(self.tenant_y, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("course_id", response_data) + self.assertEqual(response_data["course_id"], [f"Invalid course_id {self.demo_course_id}"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_create_valid_user_course_unavailable_mode(self, param: str) -> None: + """ + Test creating an enrollment with a valid user, valid course, and an unavailable mode in a tenant. + + Open edX definitions tested: + - `check_edxapp_enrollment_is_valid` + - `api.validate_course_mode` + + Expected result: + - The status code is 400. + - The response contains an error message about the mode not found. + - The enrollment is not created in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": "masters", + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response_data) + self.assertEqual(response_data["non_field_errors"], ["Mode not found"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_force_create_valid_user_course_unavailable_mode(self, param: str) -> None: + """ + Test force creating an enrollment with a valid user, valid course, and an unavailable mode in a tenant. + + When `force = True`, the enrollment is created even if the mode is not available. + + Open edX definitions tested: + - `get_edxapp_user` + - `create_enrollment` + - `check_edxapp_account_conflicts` + - `check_edxapp_enrollment_is_valid` + + Expected result: + - The status code is 200. + - The enrollment is created successfully in the tenant with the provided data. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": "masters", + "force": True, + } + + response = self.create_enrollment(self.tenant_x, enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["username"], self.user["username"]) + self.assertEqual(response_data["mode"], enrollment_data["mode"]) + self.assertEqual(response_data["course_id"], enrollment_data["course_id"]) + self.assertTrue(response_data["is_active"]) + self.assertIn("created", response_data) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + + @ddt.data("email", "username") + def test_get_enrollment_success(self, param: str) -> None: + """ + Test getting an enrollment with a valid user, course and mode in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `get_enrollment` + + Expected result: + - The status code is 200. + - The response contains the enrollment data. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + self.create_enrollment(self.tenant_x, enrollment_data) + + response = self.get_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["username"], self.user["username"]) + self.assertEqual(response_data["mode"], enrollment_data["mode"]) + self.assertEqual(response_data["course_id"], enrollment_data["course_id"]) + self.assertTrue(response_data["is_active"]) + self.assertIn("created", response_data) + + @ddt.data("email", "username") + def test_get_enrollment_does_not_exist(self, param: str) -> None: + """ + Test getting an enrollment that does not exist in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `get_enrollment` + + Expected result: + - The status code is 404. + - The response contains an error message about the enrollment not found. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + } + + response = self.get_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response_data, [f"No enrollment found for user:`{self.user['username']}`"]) + + @ddt.data("email", "username") + def test_get_enrollment_not_found_in_tenant(self, param: str) -> None: + """ + Test getting an enrollment that belongs to another tenant. + + Open edX definitions tested: + - `get_edxapp_user` + + Expected result: + - The status code is 404. + - The response contains an error message about the user not found on the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + self.create_enrollment(self.tenant_x, enrollment_data) + + response = self.get_enrollment(self.tenant_y, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("detail", response_data) + self.assertEqual( + response_data["detail"], + f"No user found by {{'{param}': '{self.user[param]}'}} on site {self.tenant_y['domain']}.", + ) + + @ddt.data("email", "username") + def test_delete_enrollment_success(self, param: str) -> None: + """ + Test deleting an enrollment with a valid user, course and mode in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `delete_enrollment` + + Expected result: + - The status code is 204. + - The enrollment is deleted successfully in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + self.create_enrollment(self.tenant_x, enrollment_data) + + response = self.delete_enrollment(self.tenant_x, data=enrollment_data) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_delete_enrollment_does_not_exist(self, param: str) -> None: + """ + Test deleting an enrollment that does not exist in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `delete_enrollment` + + Expected result: + - The status code is 404. + - The response contains an error message about the enrollment not found. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + } + + response = self.delete_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("detail", response_data) + self.assertEqual( + response_data["detail"], + f"No enrollment found for user: `{self.user['username']}` on course_id `{self.demo_course_id}`", + ) + + @ddt.data("email", "username") + def test_delete_invalid_enrollment_for_tenant(self, param: str) -> None: + """ + Test deleting an enrollment that belongs to another tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `delete_enrollment` + + Expected result: + - The status code is 404. + - The response contains an error message about the user not found on the tenant. + - The enrollment is not deleted in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "mode": self.mode, + } + self.create_enrollment(self.tenant_x, enrollment_data) + + response = self.delete_enrollment(self.tenant_y, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn("detail", response_data) + self.assertEqual( + response_data["detail"], + f"No user found by {{'{param}': '{self.user[param]}'}} on site {self.tenant_y['domain']}.", + ) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + + @ddt.data("email", "username") + def test_update_valid_enrollment_change_is_active_mode_field(self, param: str) -> None: + """ + Test updating an existing enrollment. Update `is_active` and `mode` field. + + Open edX definitions tested: + - `get_edxapp_user` + - `update_enrollment` + + Expected result: + - The status code is 200. + - The enrollment is updated successfully in the tenant with the provided data. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "is_active": False, + "mode": self.mode, + } + self.create_enrollment(self.tenant_x, enrollment_data) + enrollment_data["is_active"] = True + enrollment_data["mode"] = "honor" + + response = self.update_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["user"], self.user["username"]) + self.assertEqual(response_data["course_id"], enrollment_data["course_id"]) + self.assertEqual(response_data["mode"], enrollment_data["mode"]) + self.assertTrue(response_data["is_active"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + enrollment_response_data = enrollment_response.json() + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollment_response_data["mode"], enrollment_data["mode"]) + self.assertTrue(enrollment_response_data["is_active"]) + + @ddt.data("email", "username") + def test_update_valid_enrollment_update_unavailable_mode(self, param: str) -> None: + """ + Test updating an existing enrollment. Update to an unavailable mode. + + Open edX definitions tested: + - `get_edxapp_user` + - `update_enrollment` + + Expected result: + - The status code is 400. + - The response contains an error message about the mode not found. + - The enrollment is not updated in the tenant. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "is_active": True, + "mode": self.mode, + } + enrollment_data_copy = deepcopy(enrollment_data) + self.create_enrollment(self.tenant_x, enrollment_data) + enrollment_data["mode"] = "masters" + + response = self.update_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("non_field_errors", response_data) + self.assertEqual(response_data["non_field_errors"], ["Mode not found"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data_copy) + enrollment_response_data = enrollment_response.json() + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollment_response_data["mode"], self.mode) + + @ddt.data("email", "username") + def test_update_enrollment_does_not_exist(self, param: str) -> None: + """ + Test updating an enrollment that does not exist in a tenant. + + Open edX definitions tested: + - `get_edxapp_user` + - `update_enrollment` + + Expected result: + - The status code is 202. + - The response contains an error message about the enrollment not found. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "is_active": False, + "mode": self.mode, + } + + response = self.update_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response_data["error"]["detail"], f"No enrollment found for {self.user['username']}") + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + self.assertEqual(enrollment_response.status_code, status.HTTP_404_NOT_FOUND) + + @ddt.data("email", "username") + def test_update_valid_enrollment_using_force_flag(self, param: str) -> None: + """ + Test updating an existing enrollment. Update `is_active` and `mode` field using force flag. + + When `force = True`, the enrollment is updated even if the mode is not available. + + Open edX definitions tested: + - `get_edxapp_user` + - `update_enrollment` + + Expected result: + - The status code is 200. + - The response indicates the enrollment was updated successfully. + - The enrollment is updated in the tenant with the provided data. + """ + enrollment_data = { + param: self.user[param], + "course_id": self.demo_course_id, + "is_active": False, + "mode": self.mode, + "force": True, + } + self.create_enrollment(self.tenant_x, enrollment_data) + enrollment_data["is_active"] = True + enrollment_data["mode"] = "honor" + + response = self.create_enrollment(self.tenant_x, data=enrollment_data) + + response_data = response.json() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response_data["username"], self.user["username"]) + self.assertEqual(response_data["course_id"], enrollment_data["course_id"]) + self.assertEqual(response_data["mode"], enrollment_data["mode"]) + self.assertTrue(response_data["is_active"]) + enrollment_response = self.get_enrollment(self.tenant_x, data=enrollment_data) + enrollment_response_data = enrollment_response.json() + self.assertEqual(enrollment_response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollment_response_data["mode"], enrollment_data["mode"]) + self.assertTrue(enrollment_response_data["is_active"]) + + class TestInfoView(BaseAPIIntegrationTest): """ Integration test suite for the info view. @@ -389,7 +1036,7 @@ def test_info_view_success(self) -> None: - The status code is 200. - The response contains the version, name and git commit hash. """ - response = self.make_request(self.default_site, "GET", url=self.url, with_auth=False) + response = make_request(self.default_site, "GET", url=self.url, with_auth=False) response_data = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/eox_core/settings/test.py b/eox_core/settings/test.py index 2b3d296cf..7e6d154e0 100644 --- a/eox_core/settings/test.py +++ b/eox_core/settings/test.py @@ -112,4 +112,6 @@ def plugin_settings(settings): # pylint: disable=function-redefined "API_TIMEOUT": 5, "CLIENT_ID": "client_id", "CLIENT_SECRET": "client_secret", + "DEMO_COURSE_ID": os.environ.get("DEMO_COURSE_ID", "course-v1:OpenedX+DemoX+DemoCourse") } + diff --git a/fixtures/initial_data.json b/fixtures/initial_data.json index d4d13f606..987be7622 100644 --- a/fixtures/initial_data.json +++ b/fixtures/initial_data.json @@ -45,14 +45,28 @@ "name": "TenantY" } }, + { + "model": "eox_tenant.tenantorganization", + "pk": 3, + "fields": { + "name": "OpenedX" + } + }, + { + "model": "eox_tenant.tenantorganization", + "pk": 4, + "fields": { + "name": "edX" + } + }, { "model": "organizations.organization", - "pk": 1, + "pk": 3, "fields": { - "created": "2024-09-11T17:15:30.708Z", - "modified": "2024-09-11T17:15:30.708Z", - "name": "TenantX", - "short_name": "Tenant-x", + "created": "2024-09-11T17:15:35.694Z", + "modified": "2024-09-11T17:15:35.694Z", + "name": "OpenedX", + "short_name": "openedx", "description": null, "logo": "", "active": true @@ -60,12 +74,12 @@ }, { "model": "organizations.organization", - "pk": 2, + "pk": 4, "fields": { "created": "2024-09-11T17:15:35.694Z", "modified": "2024-09-11T17:15:35.694Z", - "name": "TenantY", - "short_name": "Tenant-y", + "name": "edX", + "short_name": "edx", "description": null, "logo": "", "active": true @@ -76,10 +90,19 @@ "pk": 1, "fields": { "external_key": "tenant-x-key", - "lms_configs": "{\n \"EDNX_USE_SIGNAL\": true,\n \"PLATFORM_NAME\": \"Tenant X\",\n \"SITE_NAME\": \"tenant-x.local.edly.io\",\n \"course_org_filter\": [\n \"TenantX\"\n ]\n}", - "studio_configs": "{}", - "theming_configs": "{}", - "meta": "{}", + "lms_configs": { + "EDNX_USE_SIGNAL": true, + "PLATFORM_NAME": "Tenant X", + "SITE_NAME": "tenant-x.local.edly.io", + "course_org_filter": [ + "TenantX", + "OpenedX", + "edX" + ] + }, + "studio_configs": {}, + "theming_configs": {}, + "meta": {}, "organizations": [ 1 ] @@ -90,10 +113,17 @@ "pk": 2, "fields": { "external_key": "tenant-y-key", - "lms_configs": "{\n \"EDNX_USE_SIGNAL\": true,\n \"PLATFORM_NAME\": \"Tenant Y\",\n \"SITE_NAME\": \"tenant-y.local.edly.io\",\n \"course_org_filter\": [\n \"TenantY\"\n ]\n}", - "studio_configs": "{}", - "theming_configs": "{}", - "meta": "{}", + "lms_configs": { + "EDNX_USE_SIGNAL": true, + "PLATFORM_NAME": "Tenant Y", + "SITE_NAME": "tenant-y.local.edly.io", + "course_org_filter": [ + "TenantY" + ] + }, + "studio_configs": {}, + "theming_configs": {}, + "meta": {}, "organizations": [ 2 ] @@ -133,5 +163,85 @@ "updated": "2024-09-06T13:30:17.347Z", "algorithm": "" } + }, + { + "model": "course_modes.coursemode", + "pk": 1, + "fields": { + "course": "course-v1:OpenedX+DemoX+DemoCourse", + "mode_slug": "audit", + "mode_display_name": "audit mode", + "min_price": 0, + "currency": "usd", + "_expiration_datetime": null, + "expiration_datetime_is_explicit": false, + "expiration_date": null, + "suggested_prices": "", + "description": null, + "sku": null, + "android_sku": null, + "ios_sku": null, + "bulk_sku": null + } + }, + { + "model": "course_modes.coursemode", + "pk": 2, + "fields": { + "course": "course-v1:OpenedX+DemoX+DemoCourse", + "mode_slug": "honor", + "mode_display_name": "honor mode", + "min_price": 0, + "currency": "usd", + "_expiration_datetime": null, + "expiration_datetime_is_explicit": false, + "expiration_date": null, + "suggested_prices": "", + "description": null, + "sku": null, + "android_sku": null, + "ios_sku": null, + "bulk_sku": null + } + }, + { + "model": "course_modes.coursemode", + "pk": 3, + "fields": { + "course": "course-v1:edX+DemoX+Demo_Course", + "mode_slug": "audit", + "mode_display_name": "audit mode", + "min_price": 0, + "currency": "usd", + "_expiration_datetime": null, + "expiration_datetime_is_explicit": false, + "expiration_date": null, + "suggested_prices": "", + "description": null, + "sku": null, + "android_sku": null, + "ios_sku": null, + "bulk_sku": null + } + }, + { + "model": "course_modes.coursemode", + "pk": 4, + "fields": { + "course": "course-v1:edX+DemoX+Demo_Course", + "mode_slug": "honor", + "mode_display_name": "honor mode", + "min_price": 0, + "currency": "usd", + "_expiration_datetime": null, + "expiration_datetime_is_explicit": false, + "expiration_date": null, + "suggested_prices": "", + "description": null, + "sku": null, + "android_sku": null, + "ios_sku": null, + "bulk_sku": null + } } ]