diff --git a/eox_nelp/pearson_vue/data_classes.py b/eox_nelp/pearson_vue/data_classes.py new file mode 100644 index 00000000..275c14ca --- /dev/null +++ b/eox_nelp/pearson_vue/data_classes.py @@ -0,0 +1,78 @@ +""" +Module to add data_classes related Pearson Vue Integration +""" +from pydantic import BaseModel, Field + + +class Phone(BaseModel): + """Phone data class model""" + phone_number: str = Field(alias="phoneNumber", min_length=1, max_length=20) + phone_country_code: str = Field(alias="phoneCountryCode", min_length=1, max_length=3) + + +class Mobile(BaseModel): + """Mobile data class model""" + mobile_number: str = Field(alias="mobileNumber", min_length=1, max_length=20) + mobile_country_code: str = Field(alias="mobileCountryCode", min_length=1, max_length=3) + + +class NativeAddress(BaseModel): + """NativeAddress data class model""" + language: str = Field(alias="language", min_length=3, max_length=3) + potential_mismatch: str = Field(alias="potentialMismatch", min_length=1) + first_name: str = Field(alias="firstName", min_length=1, max_length=30) + last_name: str = Field(alias="lastName", min_length=1, max_length=50) + address1: str = Field(alias="address1", min_length=1, max_length=40) + city: str = Field(alias="city", min_length=1, max_length=32) + + +class Address(BaseModel): + """Address data class model""" + address1: str = Field(alias="address1", min_length=1, max_length=40) + city: str = Field(alias="city", min_length=1, max_length=32) + country: str = Field(alias="country", min_length=1, max_length=3) + phone: Phone = Field(alias="phone") + mobile: Mobile = Field(alias="mobile") + native_address: NativeAddress = Field(alias="nativeAddress") + + +class PrimaryAddress(Address): + """PrimaryAddress data class model""" + + +class AlternateAddress(Address): + """AlternateAddress data class model""" + + +class CandidateName(BaseModel): + """CandidateName data class model""" + first_name: str = Field(alias="firstName", min_length=1, max_length=30) + last_name: str = Field(alias="lastName", min_length=1, max_length=50) + + +class WebAccountInfo(BaseModel): + """WebAccountInfo data class model""" + email: str = Field(alias="email", min_length=1, max_length=255) + + +class CddRequest(BaseModel): + """CddRequest data class model""" + client_candidate_id: str = Field(alias="@clientCandidateID", min_length=1, max_length=50) + client_id: str = Field(alias="@clientID", min_length=1) + candidate_name: CandidateName = Field(alias="candidateName") + last_update: str = Field(alias="lastUpdate", min_length=1) + primary_address: PrimaryAddress = Field(alias="primaryAddress") + web_account_info: WebAccountInfo = Field(alias="webAccountInfo") + + +class EadRequest(BaseModel): + """EadRequest data class model""" + client_id: str = Field(alias="@clientID", min_length=1) + authorization_transaction_type: str = Field(alias="@authorizationTransactionType", min_length=1) + client_authorization_id: str = Field(alias="@clientAuthorizationID", min_length=1, max_length=25) + client_candidate_id: str = Field(alias="clientCandidateID", min_length=1, max_length=50) + exam_authorization_count: int = Field(alias="examAuthorizationCount") + exam_series_code: str = Field(alias="examSeriesCode", min_length=1, max_length=20) + elegibility_appt_date_first: str = Field(alias="eligibilityApptDateFirst", min_length=1) + elegibility_appt_date_last: str = Field(alias="eligibilityApptDateLast", min_length=1) + last_update: str = Field(alias="lastUpdate", min_length=1) diff --git a/eox_nelp/pearson_vue/exceptions.py b/eox_nelp/pearson_vue/exceptions.py new file mode 100644 index 00000000..13ab50c2 --- /dev/null +++ b/eox_nelp/pearson_vue/exceptions.py @@ -0,0 +1,91 @@ +""" +Module to add managed exceptions related Pearson Vue Integration +""" +import inspect + + +class PearsonBaseError(Exception): + """Pearson Base error class + Most classes that inherit from this class must have exception_type. + This class could have a dict representation with the following shape: + exception_dict = { + 'exception_type': 'validation-error', + 'pipe_args_dict': { + "cdd_request": {} + }, + 'pipe_function': 'validate_cdd_request', + 'exception_reason': "error: ['String to short.']" + } + """ + exception_type = "base-error" + + def __init__( + self, + exception_reason, + *args, + pipe_frame=None, + pipe_args_dict=None, + pipe_function=None, + **kwargs + ): # pylint: disable=unused-argument + """Init pearson exception.Is mandatory the exception_reasons. + You could init using pipe_frame. + Or init using exception_dict representation, + so you could init the class with PearsonBaseError(**exception_dict) + """ + + self.exception_type = self.exception_type + self.exception_reason = exception_reason + + if pipe_frame: + arg_info = inspect.getargvalues(pipe_frame) + self.pipe_args_dict = {arg: arg_info.locals[arg] for arg in arg_info.args} + self.pipe_function = pipe_frame.f_code.co_name + else: + self.pipe_args_dict = pipe_args_dict + self.pipe_function = pipe_function + + super().__init__(self.to_dict(), *args) + + def to_dict(self): + """ + Returns a dictionary representation of the class instance. + + Returns: + A dictionary containing the instance's attributes as key-value pairs. + """ + + return {key: value for key, value in self.__dict__.items() if not key.startswith('_')} + + @classmethod + def from_dict(cls, exception_dict: dict): + """Create an instance of Person or its subclass from a dictionary. + Returns: + Matched instance of pearson exception subclass initialized. + If not matched returns the base class initialized by default. + """ + exception_type = exception_dict.get('exception_type') + for subclass in cls.__subclasses__(): + if subclass.exception_type == exception_type: + return subclass(**exception_dict) + + # Default to Person if no matching subclass is found + return cls(**exception_dict) + + +class PearsonKeyError(PearsonBaseError): + """Pearson Key error class + """ + exception_type = "key-error" + + +class PearsonAttributeError(PearsonBaseError): + """Pearson Attribute error class + """ + exception_type = "attribute-error" + + +class PearsonValidationError(PearsonBaseError): + """Pearson Validation error class + """ + exception_type = "validation-error" diff --git a/eox_nelp/pearson_vue/pipeline.py b/eox_nelp/pearson_vue/pipeline.py index cecf0cd5..6d313bae 100644 --- a/eox_nelp/pearson_vue/pipeline.py +++ b/eox_nelp/pearson_vue/pipeline.py @@ -12,16 +12,25 @@ import_exam_authorization: Imports exam authorization data. get_exam_data: Retrieves exam data. """ +import inspect import logging import phonenumbers from django.conf import settings from django.contrib.auth import get_user_model from django.utils import timezone +from pydantic import ValidationError from eox_nelp.api_clients.pearson_rti import PearsonRTIApiClient from eox_nelp.edxapp_wrapper.student import anonymous_id_for_user from eox_nelp.pearson_vue.constants import PAYLOAD_CDD, PAYLOAD_EAD, PAYLOAD_PING_DATABASE +from eox_nelp.pearson_vue.data_classes import CddRequest, EadRequest +from eox_nelp.pearson_vue.exceptions import ( + PearsonAttributeError, + PearsonBaseError, + PearsonKeyError, + PearsonValidationError, +) from eox_nelp.pearson_vue.utils import generate_client_authorization_id, update_xml_with_dict from eox_nelp.signals.utils import get_completed_and_graded @@ -32,6 +41,7 @@ def audit_method(action): # pylint: disable=unused-argument """Identity audit_method""" return lambda x: x + logger = logging.getLogger(__name__) User = get_user_model() @@ -300,8 +310,13 @@ def get_exam_data(user_id, course_id, **kwargs): # pylint: disable=unused-argum Raises: Exception: If the Pearson VUE RTI service does not accept the import request. """ - courses_data = getattr(settings, "PEARSON_RTI_COURSES_DATA") - exam_metadata = courses_data[course_id] + try: + courses_data = getattr(settings, "PEARSON_RTI_COURSES_DATA") + exam_metadata = courses_data[course_id] + except KeyError as exc: + raise PearsonKeyError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc + except AttributeError as a_exc: + raise PearsonAttributeError(exception_reason=str(a_exc), pipe_frame=inspect.currentframe()) from a_exc # This generates the clientAuthorizationID based on the user_id and course_id exam_metadata["client_authorization_id"] = generate_client_authorization_id( @@ -337,39 +352,45 @@ def build_cdd_request(profile_metadata, **kwargs): # pylint: disable=unused-arg Returns: dict: dict with ead_request dict. """ - cdd_request = { - "@clientCandidateID": f'NELC{profile_metadata["anonymous_user_id"]}', - "@clientID": getattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"), - "candidateName": { - "firstName": profile_metadata["first_name"], - "lastName": profile_metadata["last_name"], - }, - "webAccountInfo": { - "email": profile_metadata["email"], - }, - "lastUpdate": timezone.now().strftime("%Y/%m/%d %H:%M:%S GMT"), - "primaryAddress": { - "address1": profile_metadata["address"], - "city": profile_metadata["city"], - "country": profile_metadata["country"], - "phone": { - "phoneNumber": profile_metadata["phone_number"], - "phoneCountryCode": profile_metadata["phone_country_code"], + try: + cdd_request = { + "@clientCandidateID": f'NELC{profile_metadata["anonymous_user_id"]}', + "@clientID": getattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"), + "candidateName": { + "firstName": profile_metadata["first_name"], + "lastName": profile_metadata["last_name"], }, - "mobile": { - "mobileNumber": profile_metadata["mobile_number"], - "mobileCountryCode": profile_metadata["mobile_country_code"], + "webAccountInfo": { + "email": profile_metadata["email"], }, - "nativeAddress": { - "language": getattr(settings, "PEARSON_RTI_NATIVE_ADDRESS_LANGUAGE", "UKN"), - "potentialMismatch": "false", - "firstName": profile_metadata["arabic_name"], - "lastName": profile_metadata["arabic_name"], + "lastUpdate": timezone.now().strftime("%Y/%m/%d %H:%M:%S GMT"), + "primaryAddress": { "address1": profile_metadata["address"], "city": profile_metadata["city"], - }, + "country": profile_metadata["country"], + "phone": { + "phoneNumber": profile_metadata["phone_number"], + "phoneCountryCode": profile_metadata["phone_country_code"], + }, + "mobile": { + "mobileNumber": profile_metadata["mobile_number"], + "mobileCountryCode": profile_metadata["mobile_country_code"], + }, + "nativeAddress": { + "language": getattr(settings, "PEARSON_RTI_NATIVE_ADDRESS_LANGUAGE", "UKN"), + "potentialMismatch": "false", + "firstName": profile_metadata["arabic_name"], + "lastName": profile_metadata["arabic_name"], + "address1": profile_metadata["address"], + "city": profile_metadata["city"], + }, + } } - } + except KeyError as exc: + raise PearsonKeyError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc + except AttributeError as a_exc: + raise PearsonAttributeError(exception_reason=str(a_exc), pipe_frame=inspect.currentframe()) from a_exc + return { "cdd_request": cdd_request } @@ -392,18 +413,91 @@ def build_ead_request( Returns: dict: dict with ead_request dict """ - ead_request = { - "@clientAuthorizationID": exam_metadata["client_authorization_id"], - "@clientID": getattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"), - "@authorizationTransactionType": transaction_type, - "clientCandidateID": f'NELC{profile_metadata["anonymous_user_id"]}', - "examAuthorizationCount": exam_metadata["exam_authorization_count"], - "examSeriesCode": exam_metadata["exam_series_code"], - "eligibilityApptDateFirst": exam_metadata["eligibility_appt_date_first"], - "eligibilityApptDateLast": exam_metadata["eligibility_appt_date_last"], - "lastUpdate": timezone.now().strftime("%Y/%m/%d %H:%M:%S GMT"), - } + try: + ead_request = { + "@clientAuthorizationID": exam_metadata["client_authorization_id"], + "@clientID": getattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"), + "@authorizationTransactionType": transaction_type, + "clientCandidateID": f'NELC{profile_metadata["anonymous_user_id"]}', + "examAuthorizationCount": exam_metadata["exam_authorization_count"], + "examSeriesCode": exam_metadata["exam_series_code"], + "eligibilityApptDateFirst": exam_metadata["eligibility_appt_date_first"], + "eligibilityApptDateLast": exam_metadata["eligibility_appt_date_last"], + "lastUpdate": timezone.now().strftime("%Y/%m/%d %H:%M:%S GMT"), + } + except KeyError as exc: + raise PearsonKeyError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc + except AttributeError as a_exc: + raise PearsonAttributeError(exception_reason=str(a_exc), pipe_frame=inspect.currentframe()) from a_exc return { "ead_request": ead_request } + + +def validate_cdd_request(cdd_request, **kwargs): # pylint: disable=unused-argument): + """ + Validates a CDD request dictionary using a Pydantic model. + + This function attempts to create a Pydantic model instance (likely named `class CddRequest`: +`) + from the provided `cdd_request` dictionary. It performs data validation based on the + model's data type definitions. + Then if there is an error then that error is raised using audit. PearsonValidationError + + Args: + cdd_request (dict): The dictionary containing the CDD request data. + """ + try: + CddRequest(**cdd_request) + except ValidationError as exc: + raise PearsonValidationError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc + + +def validate_ead_request(ead_request, **kwargs): # pylint: disable=unused-argument + """ + Validates an EAD request dictionary using a Pydantic model. + + This function attempts to create a Pydantic model instance (likely named `EadRequest`) + from the provided `ead_request` dictionary. It performs data validation based on the + model's data type definitions. + Then if there is an error then that error is raised using PearsonValidationError + + Args: + ead_request (dict): The dictionary containing the EAD request data. + """ + try: + EadRequest(**ead_request) + except ValidationError as exc: + raise PearsonValidationError(exception_reason=str(exc), pipe_frame=inspect.currentframe()) from exc + + +def audit_pearson_error(failed_step_pipeline="", exception_dict=None, **kwargs): # pylint: disable=unused-argument + """ + Method to save an error with eox-audit. + Args: + exception_dict(dict): dict presentation of the dict. + failed_step_pipeline(str): Name of function of step failed. + the audit model and the logger error. + **kwargs + Logs: + LogError: log pearsonsubclass error subclass matched. + Returns: + None + """ + if not exception_dict: + return + + if not failed_step_pipeline: + failed_step_pipeline = exception_dict.get("pipe_function") + + audit_action = f"Pearson Vue Exception~{exception_dict['exception_type']}" + + @audit_method(action=audit_action) + def raise_audit_pearson_exception(exception_dict, failed_step_pipeline): + raise PearsonBaseError.from_dict(exception_dict) + + try: + raise_audit_pearson_exception(failed_step_pipeline=failed_step_pipeline, exception_dict=exception_dict) + except PearsonBaseError as exc: + logger.error(exc) diff --git a/eox_nelp/pearson_vue/rti_backend.py b/eox_nelp/pearson_vue/rti_backend.py index 644fc56c..69eb46ad 100644 --- a/eox_nelp/pearson_vue/rti_backend.py +++ b/eox_nelp/pearson_vue/rti_backend.py @@ -5,7 +5,11 @@ Classes: RealTimeImport: Class for managing RTI operations and executing the pipeline. """ +import importlib + +from eox_nelp.pearson_vue.exceptions import PearsonBaseError from eox_nelp.pearson_vue.pipeline import ( + audit_pearson_error, build_cdd_request, build_ead_request, check_service_availability, @@ -14,6 +18,8 @@ handle_course_completion_status, import_candidate_demographics, import_exam_authorization, + validate_cdd_request, + validate_ead_request, ) @@ -47,9 +53,19 @@ def run_pipeline(self): for idx, func in enumerate(pipeline[pipeline_index:]): self.backend_data["pipeline_index"] = pipeline_index + idx - result = func(**self.backend_data) or {} - self.backend_data.update(result) + try: + result = func(**self.backend_data) or {} + except PearsonBaseError as pearson_error: + tasks = importlib.import_module("eox_nelp.pearson_vue.tasks") + tasks.rti_error_handler_task.delay( + failed_step_pipeline=func.__name__, + exception_dict=pearson_error.to_dict(), + course_id=self.backend_data.get("course_id"), + user_id=self.backend_data.get("user_id"), + ) + break + self.backend_data.update(result) if result.get("safely_pipeline_termination"): self.backend_data["pipeline_index"] = len(pipeline) - 1 break @@ -63,7 +79,9 @@ def get_pipeline(self): get_user_data, get_exam_data, build_cdd_request, + validate_cdd_request, build_ead_request, + validate_ead_request, check_service_availability, import_candidate_demographics, import_exam_authorization, @@ -80,6 +98,7 @@ def get_pipeline(self): get_user_data, get_exam_data, build_ead_request, + validate_ead_request, check_service_availability, import_exam_authorization, ] @@ -94,6 +113,18 @@ def get_pipeline(self): return [ get_user_data, build_cdd_request, + validate_cdd_request, check_service_availability, import_candidate_demographics, ] + + +class ErrorRealTimeImportHandler(RealTimeImport): + """Class for managing validation error pipe executing the pipeline for data validation.""" + def get_pipeline(self): + """ + Returns the error validation pipeline, which is a list of functions to be executed. + """ + return [ + audit_pearson_error, + ] diff --git a/eox_nelp/pearson_vue/tasks.py b/eox_nelp/pearson_vue/tasks.py index 74a79e3c..d9802af7 100644 --- a/eox_nelp/pearson_vue/tasks.py +++ b/eox_nelp/pearson_vue/tasks.py @@ -9,6 +9,7 @@ from eox_nelp.pearson_vue.rti_backend import ( CandidateDemographicsDataImport, + ErrorRealTimeImportHandler, ExamAuthorizationDataImport, RealTimeImport, ) @@ -72,3 +73,23 @@ def cdd_task(self, pipeline_index=0, **kwargs): cdd.run_pipeline() except Exception as exc: # pylint: disable=broad-exception-caught self.retry(exc=exc, kwargs=cdd.backend_data) + + +@shared_task(bind=True) +def rti_error_handler_task(self, pipeline_index=0, **kwargs): + """ + Performs an asynchronous call to manage Pearson validation error task. + + This task initiates the real-time import process using the provided pipeline index and optional keyword arguments. + + Args: + self: The Celery task instance. + pipeline_index (int): The index of the pipeline to be executed (default is 0). + **kwargs: Additional keyword arguments to configure the RTI service. + """ + error_rti = ErrorRealTimeImportHandler(pipeline_index=pipeline_index, **kwargs.copy()) + + try: + error_rti.run_pipeline() + except Exception as exc: # pylint: disable=broad-exception-caught + self.retry(exc=exc, kwargs=error_rti.backend_data) diff --git a/eox_nelp/pearson_vue/tests/test_exceptions.py b/eox_nelp/pearson_vue/tests/test_exceptions.py new file mode 100644 index 00000000..6d205428 --- /dev/null +++ b/eox_nelp/pearson_vue/tests/test_exceptions.py @@ -0,0 +1,142 @@ +""" +This module contains unit tests for the RealTimPearsonBaseError class and its methods in exception.py. +""" +import unittest +from unittest.mock import MagicMock, Mock, patch + +from ddt import data, ddt + +from eox_nelp.pearson_vue.exceptions import ( + PearsonAttributeError, + PearsonBaseError, + PearsonKeyError, + PearsonValidationError, +) + + +@ddt +class PearsonBaseErrorTest(unittest.TestCase): + """Class to test the PearsonBaseError class""" + def test_init_with_exception_reason(self): + """Tests initialization with a simple exception reason string. + Expected behavior: + - Atribute exception_type of exception class is the expected. + - Atribute exception_reason of exception class is the expected. + - Atribute pipe_args_dict of exception class is the expected. + - Atribute pipe_function of exception class is the expected. + """ + + exception_reason = "Test error message" + + error = PearsonBaseError(exception_reason) + + self.assertEqual(error.exception_type, "base-error") + self.assertEqual(error.exception_reason, exception_reason) + self.assertIsNone(error.pipe_args_dict) + self.assertIsNone(error.pipe_function) + + @patch("eox_nelp.pearson_vue.exceptions.inspect") + def test_init_with_pipe_frame(self, inspect_mock): + """Tests initialization using a pipe function frame. + Expected behavior: + - Atribute exception_type of exception class is the expected. + - Atribute exception_reason of exception class is the expected. + - Atribute pipe_args_dict of exception class is the expected. + - Atribute pipe_function of exception class is the expected. + """ + mock_arg_info = MagicMock( + locals={"arg1": "value1", "arg2": "value2", "arg3": "value3"}, + args=["arg1", "arg2"], + ) + inspect_mock.getargvalues.return_value = mock_arg_info + pipe_frame_mock = Mock() + pipe_frame_mock.f_code.co_name = "mock_pipe_function" + + error = PearsonBaseError("Test error", pipe_frame=pipe_frame_mock) + + self.assertEqual(error.exception_type, "base-error") + self.assertEqual(error.exception_reason, "Test error") + self.assertEqual(error.pipe_args_dict, {"arg1": "value1", "arg2": "value2"}) + self.assertEqual(error.pipe_function, "mock_pipe_function") + + def test_init_with_pipe_args_dict_and_pipe_function(self): + """Tests initialization with explicit pipe arguments and function. + Expected behavior: + - Atribute exception_type of exception class is the expected. + - Atribute exception_reason of exception class is the expected. + - Atribute pipe_args_dict of exception class is the expected. + - Atribute pipe_function of exception class is the expected. + """ + + pipe_args_dict = {"key1": "value1", "key2": "value2"} + pipe_function = "my_pipe_function" + + error = PearsonBaseError( + "Test error", pipe_args_dict=pipe_args_dict, pipe_function=pipe_function + ) + + self.assertEqual(error.exception_type, "base-error") + self.assertEqual(error.exception_reason, "Test error") + self.assertEqual(error.pipe_args_dict, pipe_args_dict) + self.assertEqual(error.pipe_function, pipe_function) + + def test_to_dict(self): + """Tests conversion of the object to a dictionary. + Expected behavior: + - to_dict representation is equivalent to dict used to generate the exception + """ + + exception_dict = { + "exception_type": "base-error", + "pipe_args_dict": {"cdd_request": {}}, + "pipe_function": "validate_cdd_request", + "exception_reason": "error: ['String to short.']", + } + + error = PearsonBaseError(**exception_dict) + + self.assertEqual(error.to_dict(), exception_dict) + + def test_from_dict_base_class(self): + """Tests creating a PearsonBaseError instance from a dictionary (base class). + Expected behavior: + - from dict method return the PearsonBaseError instance. + - Atribute exception_type of exception class is the expected.(base due not match) + - Atribute exception_reason of exception class is the expected. + - Atribute pipe_args_dict of exception class is the expected. + - Atribute pipe_function of exception class is the expected. + """ + + exception_dict = { + "exception_type": "unmatching-error", + "pipe_args_dict": {"cdd_request": {}}, + "pipe_function": "validate_cdd_request", + "exception_reason": "error: ['String to short.']", + } + + error = PearsonBaseError.from_dict(exception_dict) + + self.assertIsInstance(error, PearsonBaseError) + self.assertEqual(error.exception_type, PearsonBaseError.exception_type) + self.assertEqual(error.exception_reason, exception_dict["exception_reason"]) + self.assertEqual(error.pipe_args_dict, exception_dict["pipe_args_dict"]) + self.assertEqual(error.pipe_function, exception_dict["pipe_function"]) + + @data(PearsonAttributeError, PearsonKeyError, PearsonValidationError) + def test_from_dict_subclass(self, subclass_expected): + """Tests creating a subclass instance from a dictionary (subclass). + Expected behavior: + - from dict method return the PearsonSubclassinstance. + - to_dict representation is equivalent to dict used to generate the exception + """ + exception_dict = { + "exception_type": subclass_expected.exception_type, + "pipe_args_dict": {"cdd_request": {}}, + "pipe_function": "validate_cdd_request", + "exception_reason": "error: some reason explaining the error xD", + } + + error = PearsonBaseError.from_dict(exception_dict) + + self.assertIsInstance(error, subclass_expected) + self.assertEqual(error.to_dict(), exception_dict) # Check all attributes diff --git a/eox_nelp/pearson_vue/tests/test_pipeline.py b/eox_nelp/pearson_vue/tests/test_pipeline.py index 04c9b491..5b2a5394 100644 --- a/eox_nelp/pearson_vue/tests/test_pipeline.py +++ b/eox_nelp/pearson_vue/tests/test_pipeline.py @@ -10,11 +10,14 @@ from django.test import override_settings from django.utils import timezone from django_countries.fields import Country +from pydantic.v1.utils import deep_update from eox_nelp.edxapp_wrapper.student import CourseEnrollment, anonymous_id_for_user from eox_nelp.pearson_vue import pipeline from eox_nelp.pearson_vue.constants import PAYLOAD_CDD, PAYLOAD_EAD, PAYLOAD_PING_DATABASE +from eox_nelp.pearson_vue.exceptions import PearsonAttributeError, PearsonKeyError, PearsonValidationError from eox_nelp.pearson_vue.pipeline import ( + audit_pearson_error, build_cdd_request, build_ead_request, check_service_availability, @@ -23,6 +26,8 @@ handle_course_completion_status, import_candidate_demographics, import_exam_authorization, + validate_cdd_request, + validate_ead_request, ) User = get_user_model() @@ -359,6 +364,7 @@ class TestImportCandidateDemographics(unittest.TestCase): """ Unit tests for the import_candidate_demographics function. """ + def setUp(self): """ Set up the test environment. @@ -451,6 +457,7 @@ class TestImportExamAuthorization(unittest.TestCase): """ Unit tests for the import_exam_authorization function. """ + def setUp(self): """ Set up the test environment. @@ -619,19 +626,38 @@ def test_get_exam_data_failure(self): ), ) + @override_settings() + def test_wrong_exam_metadata_key_error(self): + """ Test that the get_exam_data function raises an exception when the required settings are not found. + Expected behavior: + - Raise Pearson Vue key error. + """ + setattr(settings, "PEARSON_RTI_COURSES_DATA", {}) + + self.assertRaises(PearsonKeyError, get_exam_data, self.user.id, self.course_id) + + @override_settings() + def test_wrong_exam_metadata_attr_error(self): + """ Test that the get_exam_data function raises an exception when the required settings are not found. + Expected behavior: + - Raise Pearson Vue attribute error. + """ + if hasattr(settings, "PEARSON_RTI_COURSES_DATA"): + delattr(settings, "PEARSON_RTI_COURSES_DATA") + + self.assertRaises(PearsonAttributeError, get_exam_data, self.user.id, self.course_id) + class TestBuildCddRequest(unittest.TestCase): """ Unit tests for the build_cdd_request function. """ - @patch("eox_nelp.pearson_vue.pipeline.timezone") - def test_cdd_request(self, mock_timezone): - """ Test cdd_request is built with profile_metadata. - Expected behavior: - - The result is the expected value. + + def setUp(self): """ - mock_timezone.now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) - input_data = { + Set up test environment. + """ + self.input_data = { "profile_metadata": { "anonymous_user_id": "12345", "first_name": "John", @@ -648,28 +674,57 @@ def test_cdd_request(self, mock_timezone): } } + @patch("eox_nelp.pearson_vue.pipeline.timezone") + def test_cdd_request(self, mock_timezone): + """ Test cdd_request is built with profile_metadata. + Expected behavior: + - The result is the expected value. + """ + mock_timezone.now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + expected_output = { "cdd_request": CDD_REQUEST_SAMPLE } - result = build_cdd_request(**input_data) + result = build_cdd_request(**self.input_data) self.assertDictEqual(expected_output, result) + @patch("eox_nelp.pearson_vue.pipeline.timezone") + def test_wrong_cdd_request_key_error(self, mock_timezone): + """ Test cdd_request is not built with profile_metadata. + Expected behavior: + - Raise Pearson Vue key error. + """ + mock_timezone.now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + self.input_data["profile_metadata"] = {} + + self.assertRaises(PearsonKeyError, build_cdd_request, **self.input_data) + + @override_settings() + @patch("eox_nelp.pearson_vue.pipeline.timezone") + def test_wrong_cdd_request_attr_error(self, mock_timezone): + """ Test cdd_request is not built with profile_metadata. + Expected behavior: + - Raise Pearson Vue Attribute error. + """ + mock_timezone.now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + if hasattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"): + delattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID") + + self.assertRaises(PearsonAttributeError, build_cdd_request, **self.input_data) + class TestBuildEadRequest(unittest.TestCase): """ Unit tests for the build_cdd_request function. """ - @patch.object(timezone, "now") - def test_build_ead_request(self, mock_now): - """ Test ead_request is built with profile_metadata and exam_metadata. - Expected behavior: - - The result is the expected value. - """ - mock_now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) - input_data = { + def setUp(self): + """ + Set up test environment. + """ + self.input_data = { "profile_metadata": { "anonymous_user_id": "12345", }, @@ -681,10 +736,222 @@ def test_build_ead_request(self, mock_now): "client_authorization_id": "12345678954", }, } + + @patch.object(timezone, "now") + def test_build_ead_request(self, mock_now): + """ Test ead_request is built with profile_metadata and exam_metadata. + Expected behavior: + - The result is the expected value. + """ + mock_now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + expected_output = { "ead_request": EAD_REQUEST_SAMPLE } - result = build_ead_request(**input_data) + result = build_ead_request(**self.input_data) self.assertDictEqual(expected_output, result) + + @patch.object(timezone, "now") + def test_wrong_build_ead_request_key(self, mock_now): + """ Test ead_request is not built with profile_metadata and exam_metadata. + Expected behavior: + - Raise Pearson Vue key error. + """ + mock_now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + + self.input_data["profile_metadata"] = {} + + self.assertRaises(PearsonKeyError, build_ead_request, **self.input_data) + + @override_settings() + @patch.object(timezone, "now") + def test_wrong_build_ead_request_attr_error(self, mock_now): + """ Test ead_request is not built with profile_metadata and exam_metadata. + Expected behavior: + - Raise Pearson Vue attribute error. + """ + mock_now.return_value = timezone.datetime(2023, 5, 20, 12, 0, 0) + if hasattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID"): + delattr(settings, "PEARSON_RTI_WSDL_CLIENT_ID") + + self.assertRaises(PearsonAttributeError, build_ead_request, **self.input_data) + + +@ddt +class TestValidateCddRequest(unittest.TestCase): + """ + Unit tests for the validate_cdd_request method. + """ + + def setUp(self): + """ + Set up the test environment. + """ + self.cdd_request = { + "@clientCandidateID": "NELC12345", + "@clientID": "12345678", + "candidateName": {"firstName": "John", "lastName": "Doe"}, + "lastUpdate": "2023/05/20 12:00:00 GMT", + "primaryAddress": { + "address1": "123 Main St", + "city": "Anytown", + "country": "US", + "mobile": {"mobileCountryCode": "1", "mobileNumber": "5551234567"}, + "nativeAddress": { + "address1": "123 Main St", + "city": "Anytown", + "firstName": "فلان الفلاني", + "language": "UKN", + "lastName": "فلان الفلاني", + "potentialMismatch": "false", + }, + "phone": {"phoneCountryCode": "1", "phoneNumber": "5551234567"}, + }, + "webAccountInfo": {"email": "john.doe@example.com"}, + } + + @data( + {"@clientCandidateID": ""}, + {"@clientID": ""}, + {"candidateName": {"firstName": ""}}, + {"candidateName": {"lastName": ""}}, + {"lastUpdate": ""}, + {"primaryAddress": {"address1": ""}}, + {"primaryAddress": {"city": ""}}, + {"primaryAddress": {"country": ""}}, + {"primaryAddress": {"mobile": {"mobileCountryCode": ""}}}, + {"primaryAddress": {"mobile": {"mobileNumber": ""}}}, + {"primaryAddress": {"nativeAddress": {"address1": ""}}}, + {"primaryAddress": {"nativeAddress": {"city": ""}}}, + {"primaryAddress": {"nativeAddress": {"firstName": ""}}}, + {"primaryAddress": {"nativeAddress": {"language": ""}}}, + {"primaryAddress": {"nativeAddress": {"lastName": ""}}}, + {"primaryAddress": {"nativeAddress": {"potentialMismatch": ""}}}, + {"primaryAddress": {"phone": {"phoneCountryCode": ""}}}, + {"primaryAddress": {"phone": {"phoneNumber": ""}}}, + {"webAccountInfo": {"email": ""}}, + ) + def test_wrong_cdd_request(self, wrong_update): + """Test validator with a wrong cdd_request updating with empty string + different keys. + + Expected behavior: + - raise PearsonValidationError + """ + wrong_cdd_request = deep_update(self.cdd_request, wrong_update) + + self.assertRaises(PearsonValidationError, validate_cdd_request, wrong_cdd_request) + + def test_correct_cdd_request(self): + """Test validator with correct cdd_request. + + Expected behavior: + - The result is the expected value. + """ + self.assertIsNone(validate_cdd_request(self.cdd_request)) + + +@ddt +class TestValidateEadRequest(unittest.TestCase): + """ + Unit tests for the validate_ead_request method. + """ + + def setUp(self): + """ + Set up the test environment. + """ + self.ead_request = { + '@authorizationTransactionType': 'Add', + '@clientAuthorizationID': '12345678954', + '@clientID': '12345678', + 'clientCandidateID': 'NELC12345', + 'eligibilityApptDateFirst': '2024/07/15 11:59:59', + 'eligibilityApptDateLast': '2025/07/15 11:59:59', + 'examAuthorizationCount': 3, + 'examSeriesCode': 'ABC', + 'lastUpdate': '2023/05/20 12:00:00 GMT', + } + + @data( + {"@authorizationTransactionType": ""}, + {"@clientAuthorizationID": ""}, + {"@clientID": ""}, + {"clientCandidateID": ""}, + {"eligibilityApptDateFirst": ""}, + {"eligibilityApptDateLast": ""}, + {"examAuthorizationCount": ""}, + {"examSeriesCode": ""}, + {"lastUpdate": ""}, + ) + def test_wrong_ead_request(self, wrong_update): + """Test validator with a wrong ead_request updating with empty string + different keys. + + Expected behavior: + - raise PearsonValidationError + """ + wrong_ead_request = deep_update(self.ead_request, wrong_update) + + self.assertRaises(PearsonValidationError, validate_ead_request, wrong_ead_request) + + def test_correct_ead_request(self): + """Test validator with correct ead_request. + + Expected behavior: + - The result is the expected value. + """ + self.assertIsNone(validate_ead_request(self.ead_request)) + + +class TestAuditPipeError(unittest.TestCase): + """ + Unit tests for the audit_pearson_error method. + """ + + def test_audit_pearson_error(self): + """Test correct behaviour calling audit_pearson_error. + + Expected behavior: + - The result is the expected value(None). + - Expected log error. + """ + kwargs = { + 'exception_dict': { + 'exception_type': 'validation-error', + 'exception_reason': "error: ['String to short.']", + 'pipe_args_dict': { + "cdd_request": {} + }, + 'pipe_function': 'validate_cdd_request', + }, + "failed_step_pipeline": 'validate_cdd_request', + } + + log_error = [ + f"ERROR:{pipeline.__name__}:{str(kwargs['exception_dict'])}" + + ] + + with self.assertLogs(pipeline.__name__, level="ERROR") as logs: + self.assertIsNone(audit_pearson_error(**kwargs)) + self.assertListEqual(log_error, logs.output) + + @patch("eox_nelp.pearson_vue.pipeline.logger") + def test_not_audit_pearson_error(self, logger_mock): + """Test not behaviour calling audit_pearson_error. + If kwargs doesnt have `exception_data`. + + Expected behavior: + - The result is the expected value(None). + - Not expected log error. + """ + kwargs = { + 'exception_dict': {}, + "failed_step_pipeline": None, + } + + self.assertIsNone(audit_pearson_error(**kwargs)) + logger_mock.error.assert_not_called() diff --git a/eox_nelp/pearson_vue/tests/test_rti_backend.py b/eox_nelp/pearson_vue/tests/test_rti_backend.py index d66aa057..e3dbe27e 100644 --- a/eox_nelp/pearson_vue/tests/test_rti_backend.py +++ b/eox_nelp/pearson_vue/tests/test_rti_backend.py @@ -1,16 +1,22 @@ """ This module contains unit tests for the RealTimeImport class and its methods in rti_backend.py. """ +import inspect import unittest -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock, call, patch +from ddt import data, ddt + +from eox_nelp.pearson_vue.exceptions import PearsonAttributeError, PearsonKeyError, PearsonValidationError from eox_nelp.pearson_vue.rti_backend import ( CandidateDemographicsDataImport, + ErrorRealTimeImportHandler, ExamAuthorizationDataImport, RealTimeImport, ) +@ddt class TestRealTimeImport(unittest.TestCase): """ Unit tests for the RealTimeImport class. @@ -139,6 +145,59 @@ def test_safely_pipeline_termination(self): }, ) + @data( + PearsonValidationError(inspect.currentframe(), "error: ['String to short.']"), + PearsonKeyError(inspect.currentframe(), "eligibility_appt_date_first"), + PearsonAttributeError(inspect.currentframe(), "Settings' object has no attribute PERITA") + ) + @patch("eox_nelp.pearson_vue.tasks.rti_error_handler_task") + def test_launch_validation_error_pipeline(self, pearson_error, rti_error_handler_task_mock): + """ + Test the execution of the RTI finished after the second function call due + `launch_validation_error_pipeline` kwarg. + + Expected behavior: + - Pipeline method 1 is called with the original data. + - Pipeline method 2 is called with updated data. + - Pipeline method 3 is not called. + - Pipeline method 4 is not called. + - backend_data attribute is the expected value. + Without func3,func4 data and pipeline index in the last. + - rti_error_handler_task is called with executed__pipeline_kwargs and error_validation_kwargs. + """ + # Mock pipeline functions + func1 = MagicMock(return_value={"updated_key": "value1"}) + func1.__name__ = "first_function" + func2 = MagicMock() + func2.side_effect = pearson_error + func2.__name__ = "failed_function" + func3 = MagicMock(return_value={"additional_key": "value3"}) + func4 = MagicMock(return_value={"additional_key": "value4"}) + executed_pipeline_kwargs = { + "updated_key": "value1", + } + + self.rti.get_pipeline = MagicMock(return_value=[func1, func2, func3, func4]) + self.rti.run_pipeline() + + func1.assert_called_once_with(**self.backend_data) + func2.assert_called_once_with(pipeline_index=1, **executed_pipeline_kwargs) + func3.assert_not_called() + func4.assert_not_called() + self.assertDictEqual( + self.rti.backend_data, + { + "pipeline_index": 1, # includes the pipe executed until break due exception + **func1(), # Include data from func1 () + }, + ) + rti_error_handler_task_mock.delay.assert_called_with( + failed_step_pipeline=func2.__name__, + exception_dict=pearson_error.to_dict(), + user_id=None, + course_id=None, + ) + def test_get_pipeline(self): """ Test the retrieval of the RTI pipeline. @@ -165,3 +224,10 @@ class TestCandidateDemographicsDataImport(TestRealTimeImport): Unit tests for the rti_backend class. """ rti_backend_class = CandidateDemographicsDataImport + + +class TestErrorRealTimeImportHandler(TestRealTimeImport): + """ + Unit tests for the rti_backend class. + """ + rti_backend_class = ErrorRealTimeImportHandler diff --git a/eox_nelp/pearson_vue/tests/test_tasks.py b/eox_nelp/pearson_vue/tests/test_tasks.py index e4b542e7..189bdf59 100644 --- a/eox_nelp/pearson_vue/tests/test_tasks.py +++ b/eox_nelp/pearson_vue/tests/test_tasks.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import MagicMock, call -from eox_nelp.pearson_vue.tasks import cdd_task, ead_task, real_time_import_task +from eox_nelp.pearson_vue.tasks import cdd_task, ead_task, real_time_import_task, rti_error_handler_task class TestRealTimeImportTask(unittest.TestCase): @@ -86,3 +86,11 @@ class TestCddTask(TestRealTimeImportTask): """ import_class_patch = "eox_nelp.pearson_vue.tasks.CandidateDemographicsDataImport" import_task_function = cdd_task + + +class TestErrorValidationTask(TestRealTimeImportTask): + """ + Unit tests for the cdd_task function. + """ + import_class_patch = "eox_nelp.pearson_vue.tasks.ErrorRealTimeImportHandler" + import_task_function = rti_error_handler_task