From 43decd926657857a1ed420000aa8a6e4b4777da6 Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:25:05 +0530 Subject: [PATCH 1/7] feat!: integrate async mobile login and remove selectolax/csrf logic * feat: migrate to mobile API endpoint for direct async login * refactor: completely remove selectolax/bs4 and CSRF token management * refactor: split authenticate function to reduce cyclomatic complexity (C901) * fix: dynamically skip secret-required integration tests when credentials are not configured * fix: bind application port to environment variable for Render hosting compatibility * style: clean up inline imports and restore Google-style docstrings Simplifies the codebase by using the direct mobile authentication endpoint, removing all HTML parsing and CSRF prefetching overhead. All unit tests now mock the JSON format directly, and functional tests are skipped locally if secrets are not set in the env. --- .gitignore | 1 + app/app.py | 50 +- app/exceptions/authentication.py | 35 - app/pesu.py | 546 +++++----------- pyproject.toml | 1 - requirements.txt | 4 +- tests/conftest.py | 13 + tests/unit/test_app_unit.py | 19 +- tests/unit/test_authenticate_unit.py | 93 --- tests/unit/test_pesu.py | 934 +++------------------------ uv.lock | 46 -- 11 files changed, 269 insertions(+), 1473 deletions(-) delete mode 100644 tests/unit/test_authenticate_unit.py diff --git a/.gitignore b/.gitignore index e40bb4b..cf73cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ bruno.json .uv/ .ruff_cache/ *.csv +.DS_Store *.png diff --git a/app/app.py b/app/app.py index 7998c9a..b76f2bf 100644 --- a/app/app.py +++ b/app/app.py @@ -3,16 +3,16 @@ from __future__ import annotations import argparse -import asyncio import datetime import logging +import os from contextlib import asynccontextmanager from importlib.metadata import version from typing import TYPE_CHECKING from zoneinfo import ZoneInfo import uvicorn -from fastapi import BackgroundTasks, FastAPI +from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, RedirectResponse @@ -29,27 +29,6 @@ from app.pesu import PESUAcademy IST = ZoneInfo("Asia/Kolkata") -CSRF_TOKEN_REFRESH_INTERVAL_SECONDS = 45 * 60 -CSRF_TOKEN_REFRESH_LOCK = asyncio.Lock() - - -async def _refresh_csrf_token_with_lock() -> None: - """Refresh the CSRF token with a lock.""" - logging.debug("Refreshing unauthenticated CSRF token...") - async with CSRF_TOKEN_REFRESH_LOCK: - await pesu_academy.prefetch_client_with_csrf_token() - logging.info("Unauthenticated CSRF token refreshed successfully.") - - -async def _csrf_token_refresh_loop() -> None: - """Background task to refresh the CSRF token periodically.""" - while True: - try: - logging.debug("Refreshing unauthenticated CSRF token...") - await _refresh_csrf_token_with_lock() - except Exception: - logging.exception("Failed to refresh unauthenticated CSRF token in the background.") - await asyncio.sleep(CSRF_TOKEN_REFRESH_INTERVAL_SECONDS) @asynccontextmanager @@ -57,27 +36,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Lifespan event handler for startup and shutdown events.""" # Startup logging.info("PESUAuth API startup") - - # Prefetch PESUAcademy client for first request - await pesu_academy.prefetch_client_with_csrf_token() - logging.info("Prefetched a new PESUAcademy client with an unauthenticated CSRF token.") - - # Start the periodic CSRF token refresh background task - refresh_task = asyncio.create_task(_csrf_token_refresh_loop()) - logging.info("Started the unauthenticated CSRF token refresh background task.") - yield - # Shutdown - refresh_task.cancel() - try: - await refresh_task - except asyncio.CancelledError: - logging.debug("Unauthenticated CSRF token refresh background task cancelled.") - except Exception: - logging.exception("Failed to cancel unauthenticated CSRF token refresh background task.") - - await pesu_academy.close_client() logging.info("PESUAuth API shutdown.") @@ -188,7 +148,7 @@ async def readme() -> RedirectResponse: responses=authenticate_docs.response_examples, tags=["Authentication"], ) -async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) -> JSONResponse: +async def authenticate(payload: RequestModel) -> JSONResponse: """Authenticate a user using their PESU credentials via the PESU Academy service. Request body parameters: @@ -217,8 +177,6 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) fields=fields, ), ) - # Prefetch a new client with an unauthenticated CSRF token for the next request - background_tasks.add_task(_refresh_csrf_token_with_lock) # Validate the response try: @@ -253,7 +211,7 @@ def main() -> None: parser.add_argument( "--port", type=int, - default=5000, + default=int(os.environ.get("PORT", 5000)), help="Port to run the FastAPI application on. Default is 5000", ) parser.add_argument( diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 62560d8..7d9a1e9 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -9,38 +9,3 @@ class AuthenticationError(PESUAcademyError): def __init__(self, message: str = "Invalid username or password, or user does not exist.") -> None: """Initialize the AuthenticationError with a custom message.""" super().__init__(message, status_code=401) - - -class CSRFTokenError(PESUAcademyError): - """Raised when CSRF token is missing or cannot be extracted.""" - - def __init__(self, message: str = "CSRF token could not be extracted from the response.") -> None: - """Initialize the CSRFTokenError with a custom message.""" - super().__init__(message, status_code=502) - - -class ProfileFetchError(PESUAcademyError): - """Raised when profile data could not be fetched from PESU Academy.""" - - def __init__(self, message: str = "Failed to fetch student profile page from PESU Academy.") -> None: - """Initialize the ProfileFetchError with a custom message.""" - super().__init__(message, status_code=502) - - -class ProfileParseError(PESUAcademyError): - """Raised when profile data could not be parsed from PESU Academy.""" - - def __init__(self, message: str = "Failed to parse student profile page from PESU Academy.") -> None: - """Initialize the ProfileParseError with a custom message.""" - super().__init__(message, status_code=422) - - -class KYCASFetchError(PESUAcademyError): - """Raised when "Know Your Class and Section" data could not be fetched from PESU Academy.""" - - def __init__( - self, - message: str = 'Failed to fetch "Know Your Class and Section" data from PESU Academy.', - ) -> None: - """Initialize the "Know Your Class and Section" FetchError with a custom message.""" - super().__init__(message, status_code=502) diff --git a/app/pesu.py b/app/pesu.py index bc70371..026eb62 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -1,21 +1,15 @@ """PESUAcademy class that serves as an interface to the PESU Academy website.""" -import asyncio +from __future__ import annotations + +import json import logging import re -from datetime import datetime from typing import Any, Literal, get_args import httpx -from selectolax.parser import HTMLParser, Node -from app.exceptions.authentication import ( - AuthenticationError, - CSRFTokenError, - KYCASFetchError, - ProfileFetchError, - ProfileParseError, -) +from app.exceptions.authentication import AuthenticationError ProfileField = Literal[ "name", @@ -35,323 +29,128 @@ ] -class PESUAcademy: - """Class to interact with the PESU Academy server. +def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> str | None: + """Extract semester format (e.g. 'Sem-2') from class name or batch class.""" + for val in (class_name, batch_class): + if not val: + continue + if m := re.search(r"Sem(?:ester)?[-_ ]?(\d+)", val, re.IGNORECASE): + return f"Sem-{m.group(1)}" + if m := re.search(r"(\d+)(?:st|nd|rd|th)?[-_ ]?Sem", val, re.IGNORECASE): + return f"Sem-{m.group(1)}" + if m := re.search(r"\b(\d+)\b", val): + return f"Sem-{m.group(1)}" + return None + + +PROGRAM_MAPPING = { + "B.Tech.": "Bachelor of Technology", + "B.Tech": "Bachelor of Technology", + "M.Tech.": "Master of Technology", + "M.Tech": "Master of Technology", + "B.Arch.": "Bachelor of Architecture", + "B.Arch": "Bachelor of Architecture", + "BBA.": "Bachelor of Business Administration", + "BBA": "Bachelor of Business Administration", + "MBA.": "Master of Business Administration", + "MBA": "Master of Business Administration", +} + +BRANCH_MAPPING = { + "Branch:CSE": "Computer Science and Engineering", + "CSE": "Computer Science and Engineering", + "Branch:ECE": "Electronics and Communication Engineering", + "ECE": "Electronics and Communication Engineering", + "Branch:EEE": "Electrical and Electronics Engineering", + "EEE": "Electrical and Electronics Engineering", + "Branch:ME": "Mechanical Engineering", + "ME": "Mechanical Engineering", + "Branch:BT": "Biotechnology", + "BT": "Biotechnology", +} - This class provides methods to authenticate users, fetch profile information, and handle CSRF token management. - Attributes: - DEFAULT_FIELDS (list[str]): The default fields to fetch from the profile page. - PROFILE_PAGE_HEADER_TO_KEY_MAP (dict[str, str]): A mapping of profile page headers to the corresponding keys - in the profile dictionary. +class PESUAcademy: + """Class to interact with the PESU Academy server. - Methods: - prefetch_client_with_csrf_token: Prefetch a new client with an unauthenticated CSRF token. - close_client: Close the internal client if it exists. - get_profile_information: Get the profile information of the user. - authenticate: Authenticate the user with the provided username and password. + This class provides methods to authenticate users. """ DEFAULT_FIELDS: list[str] = list(get_args(ProfileField)) - PROFILE_PAGE_HEADER_TO_KEY_MAP = { - "Name": "name", - "PESU Id": "prn", - "SRN": "srn", - "Program": "program", - "Branch": "branch", - "Semester": "semester", - "Section": "section", - } - - KYCAS_HEADER_TO_KEY_MAP = { - "PRN": "prn", - "SRN": "srn", - "Name": "name", - "Class": "semester", - "Section": "section", - "Cycle": "cycle", - "Department": "department", - "Branch": "branch", - "Institute Name": "instituteName", - } - def __init__(self) -> None: """Initialize the PESUAcademy class.""" - self._csrf_token: str | None = None - self._client: httpx.AsyncClient | None = None - self._csrf_lock = asyncio.Lock() - - @staticmethod - async def _fetch_new_client_with_csrf_token() -> tuple[httpx.AsyncClient, str]: - """Initialize a fresh client with an unauthenticated CSRF token from PESU Academy.""" - logging.info("Fetching a new client with an unauthenticated CSRF token...") - # Create a new client - client = httpx.AsyncClient(follow_redirects=True, timeout=10.0) - # Fetch the CSRF token - resp = await client.get("https://www.pesuacademy.com/Academy/") - soup = await asyncio.to_thread(HTMLParser, resp.text) - if node := soup.css_first("meta[name='csrf-token']"): - csrf_token = node.attributes["content"] - logging.info(f"Fetched CSRF token: {csrf_token}") - return client, csrf_token - raise CSRFTokenError("CSRF token not found in the pre-authentication response.") - - async def _prefetch_client_with_csrf_token(self) -> None: - """Prefetch a new client with an unauthenticated CSRF token. - - This method is used to prefetch a new client with an unauthenticated CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - logging.info("Prefetching a new client with an unauthenticated CSRF token...") - client, token = await self._fetch_new_client_with_csrf_token() - async with self._csrf_lock: - # Close old cached client (if any) to avoid leaks - if self._client is not None: - await self._client.aclose() - # Store the new cached client/token - self._client = client - self._csrf_token = token - logging.info("Cache refreshed with new unauthenticated CSRF token.") - - async def _get_client_with_csrf_token(self) -> tuple[httpx.AsyncClient, str]: - """Get the client with the cached CSRF token. - - This method is used to get the client with the cached CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - async with self._csrf_lock: - # If cache is empty (first call), populate it - if not (self._client and self._csrf_token): - ( - self._client, - self._csrf_token, - ) = await self._fetch_new_client_with_csrf_token() - # Hand out the cached client/token for *this* request - client_to_use, token_to_use = self._client, self._csrf_token - # Immediately clear the cache so the next caller doesn't reuse this client/token - self._client = None - self._csrf_token = None - - # Kick off async prefetch for the *next* request (non-blocking) - asyncio.create_task(self._prefetch_client_with_csrf_token()) - # Return a dedicated client/token for this request - return client_to_use, token_to_use - - def _extract_and_update_profile(self, node: Node, idx: int, profile: dict) -> None: - """Extract the profile data from a node and update the profile dictionary. + pass - Args: - node (Node): Pre-parsed node containing the profile information - idx (int): Index of the node - profile (dict): The profile dictionary to update in-place - """ - # Use the selector `label.lbl-title-light` to find the key label - if not (key_node := node.css_first("label.lbl-title-light")) or not (key := key_node.text(strip=True)): - raise ProfileParseError(f"Could not parse key for field at index {idx}.") - # Use the adjacent sibling selector `+` to find value label - if not (value_node := node.css_first("label.lbl-title-light + label")) or not ( - value := value_node.text(strip=True) - ): - raise ProfileParseError(f"Could not parse value for field at index {idx}.") - logging.debug(f"Extracted key: '{key}' with value: '{value}' at index {idx}.") - # If the key is in the map, add it to the profile - if mapped_key := self.PROFILE_PAGE_HEADER_TO_KEY_MAP.get(key): - logging.debug(f"Adding key: '{mapped_key}', value: '{value}' to profile...") - profile[mapped_key] = value - else: - raise ProfileParseError( - f"Unknown key: '{key}' in the profile page. The webpage might have changed.", - ) - - async def prefetch_client_with_csrf_token(self) -> None: - """Public method to prefetch a new client with an unauthenticated CSRF token. - - This method is used to prefetch a new client with an unauthenticated CSRF token. - It is used to avoid the overhead of fetching a new client with an unauthenticated CSRF token - for each request. - """ - await self._prefetch_client_with_csrf_token() - - async def close_client(self) -> None: - """Public method to close the internal client if it exists. - - This method is used to close the internal client if it exists. - It is used to avoid the overhead of closing the client for each request. - """ - if self._client is not None: - await self._client.aclose() - self._client = None - - async def get_profile_information( + def _map_data( self, - client: httpx.AsyncClient, + data: dict[str, Any], username: str, + profile: bool, + know_your_class_and_section: bool, + fields: list[str], + field_filtering: bool, ) -> dict[str, Any]: - """Get the profile information of the user. - - Args: - client (httpx.Client): The HTTP client to use for making requests. - username (str): The username of the user, usually their PRN/email/phone number. - - Returns: - dict[str, Any]: A dictionary containing the user's profile information. - """ - # Fetch the profile data from the student profile page - logging.info(f"Fetching profile data for user={username} from the student profile page...") - profile_url = "https://www.pesuacademy.com/Academy/s/studentProfilePESUAdmin" - query = { - "menuId": "670", - "url": "studentProfilePESUAdmin", - "controllerMode": "6414", - "actionType": "5", - "id": "0", - "selectedData": "0", - "_": str(int(datetime.now().timestamp() * 1000)), - } - response = await client.get(profile_url, params=query) - # If the status code is not 200, raise an exception because the profile page is not accessible - if response.status_code != 200: - raise ProfileFetchError( - f"Failed to fetch student profile page from PESU Academy for user={username}.", - ) - logging.debug("Student profile page fetched successfully.") - - # Parse the response text - soup = await asyncio.to_thread(HTMLParser, response.text) - # Get the details container and its nodes where the profile information is stored - if ( - not (details_container := soup.css_first("div.elem-info-wrapper")) - or not (details_nodes := details_container.css("div.form-group")) - or len(details_nodes) < 7 - ): - raise ProfileParseError( - f"Failed to parse student profile page from PESU Academy for user={username}." - "The webpage might have changed.", - ) - - # Extract the profile information from the profile page - profile: dict[str, Any] = {} - for i in range(7): - self._extract_and_update_profile(details_nodes[i], i, profile) - - # Get the email and phone number from the profile page - if ( - (email_node := soup.css_first("#updateMail")) - and (email_value := email_node.attributes.get("value")) - and isinstance(email_value, str) - ): - profile["email"] = email_value.strip() - - if ( - (phone_node := soup.css_first("#updateContact")) - and (phone_value := phone_node.attributes.get("value")) - and isinstance(phone_value, str) - ): - profile["phone"] = phone_value.strip() - - # If username starts with PES1, then they are from RR campus, else if it is PES2, then EC campus - if profile.get("prn") and (campus_code_match := re.match(r"PES(\d)", profile["prn"])): - campus_code = campus_code_match.group(1) - profile["campusCode"] = int(campus_code) - if campus_code == "1": - profile["campus"] = "RR" - elif campus_code == "2": - profile["campus"] = "EC" - else: - logging.warning( - f"Unknown campus code: {campus_code} parsed from PRN={profile['prn']} for user={username}", - ) - - # Check if we extracted any profile data - if not profile: - raise ProfileParseError(f"No profile data could be extracted for user={username}.") - logging.info(f"Complete profile information retrieved for user={username}: {profile}.") - - return profile - - async def get_know_your_class_and_section( - self, - client: httpx.AsyncClient, - csrf_token: str, - username: str, - ) -> dict[str, Any]: - """Get the class and section information of the user from the "Know Your Class and Section" endpoint. - - Args: - client (httpx.AsyncClient): The authenticated HTTP client to use for making requests. - csrf_token (str): The authenticated CSRF token. - username (str): The username of the user, usually their SRN or PRN. - - Returns: - dict[str, Any]: A dictionary containing the user's class and section information. - """ - logging.info(f'Fetching class and section data for user={username} from "Know Your Class and Section" page...') - kycas_url = "https://www.pesuacademy.com/Academy/a/getStudentClassInfo" - kycas_data = {"controllerMode": "370", "actionType": "174", "loginId": username} - kycas_headers = { - "origin": "https://www.pesuacademy.com", - "referer": "https://www.pesuacademy.com/Academy/", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "x-csrf-token": csrf_token, - "x-requested-with": "XMLHttpRequest", - } - - try: - response = await client.post(kycas_url, data=kycas_data, headers=kycas_headers) - except Exception: - raise KYCASFetchError( - f'Failed to send "Know Your Class and Section" request to PESU Academy for user={username}.', - ) - - if response.status_code != 200: - raise KYCASFetchError( - f'Failed to fetch "Know Your Class and Section" data from PESU Academy for user={username}. ' - f"Received status code {response.status_code}.", - ) - - soup = await asyncio.to_thread(HTMLParser, response.text) - kycas: dict[str, Any] = {} - - table = soup.css_first("table") - if not table: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" table in the response for user={username}.', - ) - - headers = [th.text(strip=True) for th in table.css("thead th")] - if not headers: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" table headers in the response for user={username}.', - ) - - row = table.css_first("tbody tr") - if not row: - raise KYCASFetchError( - f'Could not find "Know Your Class and Section" data row in the response for user={username}.', - ) - - cells = [td.text(strip=True) for td in row.css("td")] - - if len(headers) != len(cells): - raise KYCASFetchError( - f'Mismatch between "Know Your Class and Section" table headers ({len(headers)}) ' - f"and cells ({len(cells)}) for user={username}.", - ) - - for header, cell_value in zip(headers, cells): - if mapped_key := self.KYCAS_HEADER_TO_KEY_MAP.get(header): - kycas[mapped_key] = cell_value + """Map the profile and class/section data from the response JSON.""" + prn = data.get("loginId") + srn = data.get("departmentId") or username + campus_code = None + campus = None + if prn and (campus_code_match := re.match(r"PES(\d)", prn)): + campus_code = int(campus_code_match.group(1)) + if campus_code == 1: + campus = "RR" + elif campus_code == 2: + campus = "EC" + + semester_val = _get_semester_from_class(data.get("className"), data.get("batchClass")) + program_raw = data.get("program") + program = PROGRAM_MAPPING.get(program_raw, program_raw) + branch_raw = data.get("branch") + branch = BRANCH_MAPPING.get(branch_raw, branch_raw) + + # Prepare the authentication result dictionary + result = {"status": True, "message": "Login successful."} + + # Fetch the profile information if profile details are requested + if profile: + profile_dict = { + "name": data.get("name"), + "prn": prn, + "srn": srn, + "program": program, + "branch": branch, + "semester": semester_val, + "section": data.get("sectionName"), + "email": data.get("email"), + "phone": data.get("phone"), + "campusCode": campus_code, + "campus": campus, + } + # Filter the fields if field filtering is enabled + if field_filtering: + profile_dict = {k: v for k, v in profile_dict.items() if k in fields} + result["profile"] = profile_dict - if not kycas: - raise KYCASFetchError( - f'No "Know Your Class and Section" data could be extracted for user={username}.', - ) + # Fetch the "Know Your Class and Section" data if requested + if know_your_class_and_section: + kycas_dict = { + "prn": prn, + "srn": srn, + "name": data.get("name"), + "semester": semester_val, + "section": f"Section {data.get('sectionName')}" if data.get("sectionName") else None, + "cycle": "NA", + "department": data.get("branch"), + "branch": data.get("branch"), + "instituteName": "PES University", + } + # Filter the fields if field filtering is enabled + if field_filtering: + kycas_dict = {k: v for k, v in kycas_dict.items() if k in fields} + result["knowYourClassAndSection"] = kycas_dict - logging.info(f'"Know Your Class and Section" data retrieved for user={username}: {kycas}.') - return kycas + return result async def authenticate( self, @@ -382,82 +181,69 @@ async def authenticate( field_filtering = fields != self.DEFAULT_FIELDS logging.info( - f"Connecting to PESU Academy with user={username}, profile={profile}, fields={fields} ...", + f"Connecting to PESU Academy mobile API with user={username}, profile={profile}, fields={fields} ...", ) - # Get a pre-fetched csrf token and client - client, csrf_token = await self._get_client_with_csrf_token() - logging.debug(f"Using cached CSRF token for user={username}.") - - # Prepare the login data for auth call - data = { - "_csrf": csrf_token, + # Prepare the payload for mobile login API + login_url = "https://www.pesuacademy.com/MAcademy/j_spring_security_check" + payload = { "j_username": username, "j_password": password, + "j_mobile": "MOBILE", + "j_mobileApp": "YES", + "j_social": "NO", + "j_appId": "1", + "action": "0", + "mode": "0", + "whichObjectId": "loginSubmitButton", + "randomNum": 0.5, } - logging.debug("Attempting to authenticate user...") # Make a post request to authenticate the user - auth_url = "https://www.pesuacademy.com/Academy/j_spring_security_check" - response = await client.post(auth_url, data=data) - soup = await asyncio.to_thread(HTMLParser, response.text) - logging.debug("Authentication response received.") - - # If class login-form is present, login failed - if soup.css_first("div.login-form"): - # Log the error and return the error message - raise AuthenticationError( - f"Invalid username or password, or user does not exist for user={username}.", - ) - - # If the user is successfully authenticated - logging.info(f"Login successful for user={username}.") - status = True - # Get the newly authenticated csrf token - if csrf_node := soup.css_first("meta[name='csrf-token']"): - csrf_token = csrf_node.attributes.get("content") - logging.debug(f"Authenticated CSRF token: {csrf_token}") - else: - raise CSRFTokenError( - f"CSRF token not found in the post-authentication response for user={username}.", - ) - - result = {"status": status, "message": "Login successful."} - - if profile: - logging.info(f"Profile data requested for user={username}. Fetching profile data...") - # Fetch the profile information - result["profile"] = await self.get_profile_information(client, username) - # Filter the fields if field filtering is enabled - if field_filtering: - result["profile"] = {key: value for key, value in result["profile"].items() if key in fields} - logging.info( - f"Field filtering enabled. Filtered profile data for user={username}: {result['profile']}", + async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client: + try: + response = await client.post(login_url, data=payload) + except Exception as e: + raise AuthenticationError(f"Connection failed: {str(e)}") + + # Check if the response status code is 200 + if response.status_code != 200: + raise AuthenticationError( + f"Authentication failed: Server returned status code {response.status_code}", ) - if know_your_class_and_section: - logging.info( - f'"Know Your Class and Section" data requested for user={username}. ' - 'Fetching "Know Your Class and Section" data...', - ) - # Fetch the class and section information - result["knowYourClassAndSection"] = await self.get_know_your_class_and_section( - client, - csrf_token, - username, - ) - # Filter the fields if field filtering is enabled - if field_filtering: - result["knowYourClassAndSection"] = { - key: value for key, value in result["knowYourClassAndSection"].items() if key in fields - } - logging.info( - f'Field filtering enabled. Filtered "Know Your Class and Section" data for user={username}: ' - f"{result['knowYourClassAndSection']}", + # Parse the response JSON + try: + data = response.json() + except Exception: + raise AuthenticationError("Authentication failed: Invalid JSON response from server") + + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + raise AuthenticationError("Authentication failed: Invalid nested JSON response") + + # If the response does not indicate success, raise an AuthenticationError + if not isinstance(data, dict) or data.get("login") != "SUCCESS": + error_msg = data.get("errorMessage") if isinstance(data, dict) else None + raise AuthenticationError( + error_msg or f"Invalid username or password, or user does not exist for user={username}." ) - logging.info(f"Authentication process for user={username} completed successfully.") - - # Close the client and return the result - await client.aclose() - return result + # If the user is successfully authenticated + logging.info(f"Login successful for user={username}.") + + # Return early if no profile or class section details are requested + if not profile and not know_your_class_and_section: + return {"status": True, "message": "Login successful."} + + # Map the parsed response JSON to return format + return self._map_data( + data=data, + username=username, + profile=profile, + know_your_class_and_section=know_your_class_and_section, + fields=fields, + field_filtering=field_filtering, + ) diff --git a/pyproject.toml b/pyproject.toml index bf9bf1c..241e6fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "uvicorn>=0.47.0", "httpx>=0.28.1", "pydantic>=2.13.4", - "selectolax>=0.4.9", ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index a16303e..3b65e8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,15 +34,15 @@ pydantic==2.13.4 # fastapi pydantic-core==2.46.4 # via pydantic -selectolax==0.4.9 - # via pesu-auth (pyproject.toml) starlette==1.0.1 # via fastapi typing-extensions==4.15.0 # via + # anyio # fastapi # pydantic # pydantic-core + # starlette # typing-inspection typing-inspection==0.4.2 # via diff --git a/tests/conftest.py b/tests/conftest.py index 08f428f..6ee4988 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import os +import pytest from dotenv import load_dotenv load_dotenv() @@ -11,6 +13,8 @@ def pytest_collection_modifyitems(config, items): "integration": 2, } + has_secrets = os.getenv("TEST_EMAIL") is not None and os.getenv("TEST_PASSWORD") is not None + def sort_key(item): for key, value in priority.items(): if key in str(item.fspath): @@ -18,3 +22,12 @@ def sort_key(item): return 99 items.sort(key=sort_key) + + # Automatically skip secret-required tests if credentials are not configured in the environment + if not has_secrets: + skip_marker = pytest.mark.skip( + reason="Integration/functional tests skipped because TEST_EMAIL or TEST_PASSWORD is not set." + ) + for item in items: + if "secret_required" in item.keywords: + item.add_marker(skip_marker) diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index b5d0b93..e4d644c 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -4,9 +4,7 @@ import pytest from fastapi.testclient import TestClient -from app.app import _csrf_token_refresh_loop, app, main - - +from app.app import app, main @pytest.fixture def client(): with TestClient(app, raise_server_exceptions=False) as client: @@ -38,21 +36,6 @@ def test_authenticate_general_exception(mock_authenticate, client): data = response.json() assert "Internal Server Error" in data["message"] - -@pytest.mark.asyncio -@patch("asyncio.sleep", new_callable=AsyncMock) -@patch("app.app._refresh_csrf_token_with_lock") -async def test_csrf_token_refresh_loop_logs_exception_on_failure(mock_refresh, mock_sleep, caplog): - mock_refresh.side_effect = RuntimeError("Simulated CSRF refresh failure") - mock_sleep.side_effect = asyncio.CancelledError - - with caplog.at_level("ERROR"): - with pytest.raises(asyncio.CancelledError): - await _csrf_token_refresh_loop() - - assert "Failed to refresh unauthenticated CSRF token in the background." in caplog.text - - @patch("app.app.argparse.ArgumentParser.parse_args") @patch("app.app.logging.basicConfig") @patch("app.app.uvicorn.run") diff --git a/tests/unit/test_authenticate_unit.py b/tests/unit/test_authenticate_unit.py deleted file mode 100644 index 45d9a5a..0000000 --- a/tests/unit/test_authenticate_unit.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest.mock import AsyncMock, patch - -import pytest - -from app.exceptions.authentication import AuthenticationError, CSRFTokenError -from app.pesu import PESUAcademy - - -@pytest.fixture -def pesu(): - return PESUAcademy() - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@pytest.mark.asyncio -async def test_authenticate_success_no_profile(mock_post, mock_get, pesu): - # Mock GET home page response with csrf token meta - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get_response.status_code = 200 - mock_get.return_value = mock_get_response - - # Mock POST login success response with csrf token meta - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - result = await pesu.authenticate("user", "pass", profile=False) - - assert result["status"] is True - assert result["message"] == "Login successful." - assert "profile" not in result - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_profile_information") -@pytest.mark.asyncio -async def test_authenticate_success_with_profile(mock_get_profile, mock_post, mock_get, pesu): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - mock_get_profile.return_value = { - "prn": "PES12345", - "name": "Test User", - "branch": "Computer Science and Engineering", - } - - result = await pesu.authenticate("user", "pass", profile=True, fields=["prn", "name"]) - - assert result["status"] is True - assert "profile" in result - assert "prn" in result["profile"] - assert "name" in result["profile"] - # Field filtering works, branch is omitted since not requested - assert "branch" not in result["profile"] - - -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_authenticate_csrf_fetch_failure(mock_get, pesu): - mock_get.side_effect = CSRFTokenError("CSRF fetch failed") - - with pytest.raises(CSRFTokenError): - result = await pesu.authenticate("user", "pass") - assert result["status"] is False - assert "Unable to fetch csrf token" in result["message"] - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@pytest.mark.asyncio -async def test_authenticate_login_failure(mock_post, mock_get, pesu): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - # Simulate login failure: login form div present - mock_post_response = AsyncMock() - mock_post_response.text = '
Login error
' - mock_post.return_value = mock_post_response - - with pytest.raises(AuthenticationError): - result = await pesu.authenticate("user", "pass") - - assert result["status"] is False - assert "Invalid username or password" in result["message"] diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index cff8c6d..11edaee 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -1,905 +1,135 @@ from unittest.mock import AsyncMock, MagicMock, patch - import pytest +import httpx -from app.exceptions.authentication import ( - AuthenticationError, - CSRFTokenError, - KYCASFetchError, - ProfileFetchError, - ProfileParseError, -) +from app.exceptions.authentication import AuthenticationError from app.pesu import PESUAcademy @pytest.fixture def pesu(): return PESUAcademy() - - -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_get_profile_information_http_error(mock_get, pesu): - mock_get.side_effect = Exception("HTTP request failed") - with pytest.raises(ProfileFetchError): - result = await pesu.get_profile_information(AsyncMock(), "testuser") - assert "error" in result - assert "Unable to fetch profile data" in result["error"] - - -@patch("app.pesu.httpx.AsyncClient.get") @pytest.mark.asyncio -async def test_get_profile_information_non_200_status(mock_get, pesu): - mock_response = AsyncMock() - mock_response.status_code = 404 - mock_get.return_value = mock_response - with pytest.raises(ProfileFetchError): - result = await pesu.get_profile_information(AsyncMock(), "testuser") - assert "error" in result - assert "Unable to fetch profile data" in result["error"] - - -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_authenticate_csrf_token_not_found(mock_get, pesu): - mock_response = AsyncMock() - mock_response.text = "No CSRF token here" - mock_get.return_value = mock_response - with pytest.raises(CSRFTokenError): - result = await pesu.authenticate("testuser", "testpass") - assert result["status"] is False - assert "Unable to fetch csrf token" in result["message"] - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@pytest.mark.asyncio -async def test_authenticate_post_request_failure(mock_post, mock_get, pesu): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - mock_post.side_effect = CSRFTokenError("POST request failed") - with pytest.raises(CSRFTokenError): - result = await pesu.authenticate("testuser", "testpass") - assert result["status"] is False - assert "Unable to authenticate" in result["message"] - - -@patch("app.pesu.httpx.AsyncClient.get") @patch("app.pesu.httpx.AsyncClient.post") -@pytest.mark.asyncio -async def test_authenticate_csrf_token_missing_after_login(mock_post, mock_get, pesu): - """Test authenticate when CSRF token is missing after successful login.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - mock_post_response = AsyncMock() - mock_post_response.text = "Login successful but no CSRF token" - mock_post.return_value = mock_post_response - with pytest.raises(CSRFTokenError): - result = await pesu.authenticate("testuser", "testpass") - assert result["status"] is True - assert result["message"] == "Login successful." - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_profile_information") -@pytest.mark.asyncio -async def test_authenticate_with_profile_field_filtering( - mock_get_profile, - mock_post, - mock_get, - pesu, -): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - mock_get_profile.return_value = { - "name": "Test User", - "prn": "PES12345", - "email": "test@example.com", - "branch": "Computer Science", - "campus": "RR", +async def test_authenticate_success(mock_post, pesu): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "login": "SUCCESS", + "userId": "12345", + "loginId": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "phone": "9876543210", + "email": "john@example.com", + "program": "Bachelor of Technology", + "branch": "CSE", + "className": "B.Tech-CSE-Sem_4", + "sectionName": "A", + "batchClass": "B.Tech CSE - 4th Semester" } - result = await pesu.authenticate("testuser", "testpass", profile=True, fields=["name", "email"]) - assert result["status"] is True - assert "profile" in result - assert "name" in result["profile"] - assert "email" in result["profile"] - assert "prn" not in result["profile"] - assert "branch" not in result["profile"] - assert "campus" not in result["profile"] + mock_post.return_value = mock_response + result = await pesu.authenticate("user", "pass", profile=True, know_your_class_and_section=True) -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_profile_information") -@pytest.mark.asyncio -async def test_authenticate_with_profile_no_field_filtering( - mock_get_profile, - mock_post, - mock_get, - pesu, -): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - mock_get_profile.return_value = dict.fromkeys(PESUAcademy.DEFAULT_FIELDS, "test_value") - result = await pesu.authenticate("testuser", "testpass", profile=True, fields=None) assert result["status"] is True - for field in PESUAcademy.DEFAULT_FIELDS: - assert field in result["profile"] - assert result["profile"][field] == "test_value" - - -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_get_profile_information_profile_parse_error(mock_get, mock_html_parser, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - mock_get.return_value = mock_response - mock_soup = MagicMock() - mock_soup.any_css_matches.return_value = True - mock_soup.css.return_value = [MagicMock()] * 3 - mock_html_parser.return_value = mock_soup - - client = AsyncMock() - client.get.return_value = mock_response + assert result["message"] == "Login successful." + assert "profile" in result + assert result["profile"]["name"] == "John Doe" + assert result["profile"]["prn"] == "PES1201800001" + assert result["profile"]["semester"] == "Sem-4" + assert result["profile"]["section"] == "A" + assert result["profile"]["campus"] == "RR" + assert result["profile"]["campusCode"] == 1 - with pytest.raises(ProfileParseError): - await pesu.get_profile_information(client, "testuser") + assert "knowYourClassAndSection" in result + assert result["knowYourClassAndSection"]["prn"] == "PES1201800001" + assert result["knowYourClassAndSection"]["semester"] == "Sem-4" + assert result["knowYourClassAndSection"]["section"] == "Section A" -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.httpx.AsyncClient.get") @pytest.mark.asyncio -async def test_authenticate_login_form_present(mock_get, mock_post, mock_html_parser, pesu): - mock_get_response = MagicMock() - mock_get_response.text = '' - mock_get_response.status_code = 200 - mock_get.return_value = mock_get_response - mock_soup_csrf = MagicMock() - mock_soup_csrf.css_first.side_effect = lambda selector: ( - MagicMock(attributes={"content": "fake-csrf-token"}) if selector == "meta[name='csrf-token']" else None - ) - mock_soup_login = MagicMock() - mock_soup_login.css_first.side_effect = lambda selector: (MagicMock() if selector == "div.login-form" else None) - mock_html_parser.side_effect = [mock_soup_csrf, mock_soup_login] - mock_post_response = MagicMock() - mock_post_response.text = "
" - mock_post_response.status_code = 200 - mock_post.return_value = mock_post_response - with pytest.raises(AuthenticationError): - await pesu.authenticate("testuser", "testpass") - - -@patch("app.pesu.HTMLParser") @patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_authenticate_csrf_token_missing_after_login_strict( - mock_get, - mock_post, - mock_html_parser, - pesu, -): - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - mock_post_response = AsyncMock() - mock_post_response.text = "Login successful but no CSRF token" - mock_post.return_value = mock_post_response - mock_soup = MagicMock() - - def css_first(selector): - if selector == "div.login-form": - return - if selector == "meta[name='csrf-token']": - return - return - - mock_soup.css_first.side_effect = css_first - mock_html_parser.return_value = mock_soup - with pytest.raises(CSRFTokenError): - await pesu.authenticate("testuser", "testpass") - - -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_get_profile_information_unknown_campus_code( - mock_get, - mock_html_parser, - pesu, - caplog, -): +async def test_authenticate_success_no_details(mock_post, pesu): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.text = "" - mock_get.return_value = mock_response - - def make_div(key, value): - div = MagicMock() - key_label = MagicMock() - key_label.text.return_value = key - value_label = MagicMock() - value_label.text.return_value = value - - def css_first(selector): - if selector == "label.lbl-title-light": - return key_label - if selector == "label.lbl-title-light + label": - return value_label - return None - - div.css_first.side_effect = css_first - return div - - form_group_elems = [ - make_div("Name", "Test User"), - make_div("SRN", "PES1234567"), - make_div("PESU Id", "PES3XXXXX"), - make_div("Program", "BTech"), - make_div("Branch", "Computer Science and Engineering"), - make_div("Semester", "6"), - make_div("Section", "A"), - ] - - mock_soup = MagicMock() - mock_container = MagicMock() - mock_container.css.return_value = form_group_elems - - email_node = MagicMock() - email_node.attributes = {"value": "test@example.com"} - phone_node = MagicMock() - phone_node.attributes = {"value": "1234567890"} - - def css_first(selector): - if selector == "div.elem-info-wrapper": - return mock_container - if selector == "#updateMail": - return email_node - if selector == "#updateContact": - return phone_node - return None - - mock_soup.css_first.side_effect = css_first - mock_html_parser.return_value = mock_soup - - client = AsyncMock() - client.get.return_value = mock_response - - with caplog.at_level("INFO"): - profile = await pesu.get_profile_information(client, "testuser") - assert profile["prn"] == "PES3XXXXX" - assert profile["name"] == "Test User" - assert profile["branch"] == "Computer Science and Engineering" - assert profile["email"] == "test@example.com" - assert profile["phone"] == "1234567890" - assert any( - "Unknown campus code: 3 parsed from PRN=PES3XXXXX for user=testuser" in record.message - for record in caplog.records - ) - assert any( - "Complete profile information retrieved for user=testuser" in record.message for record in caplog.records - ) - - -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_get_profile_information_campus_code_rr_ec(mock_get, mock_html_parser, pesu): - """Test that PRNs with PES1 and PES2 set the correct campus and campusCode.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - mock_get.return_value = mock_response - - def make_div(key, value): - div = MagicMock() - key_label = MagicMock() - key_label.text.return_value = key - value_label = MagicMock() - value_label.text.return_value = value - - def css_first(selector): - if selector == "label.lbl-title-light": - return key_label - if selector == "label.lbl-title-light + label": - return value_label - return None - - div.css_first.side_effect = css_first - return div - - # Subcase 1: PES1... (RR campus) - form_group_elems_rr = [ - make_div("Name", "Test User"), - make_div("SRN", "PES1234567"), - make_div("PESU Id", "PES1XXXXX"), - make_div("Program", "BTech"), - make_div("Branch", "Computer Science and Engineering"), - make_div("Semester", "6"), - make_div("Section", "A"), - ] - mock_soup_rr = MagicMock() - mock_container_rr = MagicMock() - mock_container_rr.css.return_value = form_group_elems_rr - mock_soup_rr.css_first.side_effect = ( - lambda selector: mock_container_rr if selector == "div.elem-info-wrapper" else None - ) - mock_html_parser.return_value = mock_soup_rr - - client = AsyncMock() - client.get.return_value = mock_response - - profile_rr = await pesu.get_profile_information(client, "testuser") - assert profile_rr["campusCode"] == 1 - assert profile_rr["campus"] == "RR" - - # Subcase 2: PES2... (EC campus) - form_group_elems_ec = [ - make_div("Name", "Test User"), - make_div("SRN", "PES2234567"), - make_div("PESU Id", "PES2YYYYY"), - make_div("Program", "BTech"), - make_div("Branch", "Computer Science and Engineering"), - make_div("Semester", "6"), - make_div("Section", "A"), - ] - mock_soup_ec = MagicMock() - mock_container_ec = MagicMock() - mock_container_ec.css.return_value = form_group_elems_ec - mock_soup_ec.css_first.side_effect = ( - lambda selector: mock_container_ec if selector == "div.elem-info-wrapper" else None - ) - mock_html_parser.return_value = mock_soup_ec - - profile_ec = await pesu.get_profile_information(client, "testuser") - assert profile_ec["campusCode"] == 2 - assert profile_ec["campus"] == "EC" - - -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.get") -@pytest.mark.asyncio -async def test_get_profile_information_no_profile_data(mock_get, mock_html_parser, pesu): - """Test that ProfileParseError is raised when no profile data is parsed (parsing loop runs but nothing added).""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - mock_get.return_value = mock_response - mock_soup = MagicMock() - mock_soup.any_css_matches.return_value = True - mock_soup.css.return_value = [MagicMock(text=MagicMock(return_value="foo bar")) for _ in range(7)] - mock_soup.css_first.return_value = None - mock_html_parser.return_value = mock_soup - - client = AsyncMock() - client.get.return_value = mock_response - with pytest.raises(ProfileParseError) as exc_info: - await pesu.get_profile_information(client, "testuser") - assert "Failed to parse student profile page from PESU Academy for user=testuser." in str(exc_info.value) - assert "The webpage might have changed." in str(exc_info.value) - + mock_response.json.return_value = { + "login": "SUCCESS", + "userId": "12345", + } + mock_post.return_value = mock_response + result = await pesu.authenticate("user", "pass", profile=False, know_your_class_and_section=False) -@patch("app.pesu.HTMLParser") -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=MagicMock) + assert result["status"] is True + assert result["message"] == "Login successful." + assert "profile" not in result + assert "knowYourClassAndSection" not in result @pytest.mark.asyncio -async def test_get_profile_information_empty_profile_triggers_final_parse_error( - mock_extract, - mock_get, - mock_html_parser, - pesu, -): - mock_extract.return_value = None - +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_failed_credentials(mock_post, pesu): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.text = "" - mock_get.return_value = mock_response - - mock_container = MagicMock() - mock_container.css.return_value = [MagicMock() for _ in range(7)] - mock_soup = MagicMock() - mock_soup.css_first.side_effect = lambda selector: mock_container if selector == "div.elem-info-wrapper" else None - mock_html_parser.return_value = mock_soup - - client = AsyncMock() - client.get.return_value = mock_response - - with pytest.raises(ProfileParseError) as exc_info: - await pesu.get_profile_information(client, "testuser") - assert "No profile data could be extracted for user=testuser" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_extract_and_update_profile_key_label_missing(pesu): - node = MagicMock() - node.css_first.return_value = None # key label missing - profile = {} - with pytest.raises(ProfileParseError) as exc_info: - await pesu._extract_and_update_profile(node, 0, profile) - assert "Could not parse key for field at index 0" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_extract_and_update_profile_value_label_missing(pesu): - node = MagicMock() - key_label = MagicMock() - key_label.text.return_value = "Name" - - def css_first(selector): - if selector == "label.lbl-title-light": - return key_label - if selector == "label.lbl-title-light + label": - return None # value label missing - return None - - node.css_first.side_effect = css_first - profile = {} - with pytest.raises(ProfileParseError) as exc_info: - await pesu._extract_and_update_profile(node, 0, profile) - assert "Could not parse value for field at index 0" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_extract_and_update_profile_unknown_key(pesu): - node = MagicMock() - key_label = MagicMock() - key_label.text.return_value = "UnknownKey" - value_label = MagicMock() - value_label.text.return_value = "SomeValue" - - def css_first(selector): - if selector == "label.lbl-title-light": - return key_label - if selector == "label.lbl-title-light + label": - return value_label - return None - - node.css_first.side_effect = css_first - profile = {} - with pytest.raises(ProfileParseError) as exc_info: - await pesu._extract_and_update_profile(node, 0, profile) - assert "Unknown key: 'UnknownKey' in the profile page" in str(exc_info.value) + mock_response.json.return_value = { + "login": "FAILURE", + "errorMessage": "Invalid username or password, or user does not exist" + } + mock_post.return_value = mock_response + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "wrongpass") + assert "Invalid username or password" in str(exc_info.value) -def test_default_fields_is_list(): - assert isinstance(PESUAcademy.DEFAULT_FIELDS, list) - assert "prn" in PESUAcademy.DEFAULT_FIELDS - assert "name" in PESUAcademy.DEFAULT_FIELDS - assert "srn" in PESUAcademy.DEFAULT_FIELDS - assert "program" in PESUAcademy.DEFAULT_FIELDS - assert "branch" in PESUAcademy.DEFAULT_FIELDS - assert "semester" in PESUAcademy.DEFAULT_FIELDS - assert "section" in PESUAcademy.DEFAULT_FIELDS - assert "email" in PESUAcademy.DEFAULT_FIELDS - assert "phone" in PESUAcademy.DEFAULT_FIELDS - assert "campusCode" in PESUAcademy.DEFAULT_FIELDS - assert "campus" in PESUAcademy.DEFAULT_FIELDS @pytest.mark.asyncio -async def test_get_kycas_http_exception(pesu): - """Test that the "Know Your Class and Section" fetch error is raised on request failure.""" - client = AsyncMock() - client.post.side_effect = Exception("Connection error") +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_connection_error(mock_post, pesu): + mock_post.side_effect = httpx.RequestError("Connection timed out") - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'Failed to send "Know Your Class and Section" request' in str(exc_info.value) + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Connection failed" in str(exc_info.value) @pytest.mark.asyncio -async def test_get_kycas_non_200_status(pesu): - """Test that the "Know Your Class and Section" fetch error is raised on non-200 responses.""" - client = AsyncMock() +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_non_200_status(mock_post, pesu): mock_response = MagicMock() mock_response.status_code = 500 - client.post.return_value = mock_response + mock_post.return_value = mock_response - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert "Received status code 500" in str(exc_info.value) + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Server returned status code 500" in str(exc_info.value) -@patch("app.pesu.HTMLParser") @pytest.mark.asyncio -async def test_get_kycas_no_table(mock_html_parser, pesu): - """Test that the "Know Your Class and Section" fetch error is raised when no table is found.""" - client = AsyncMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - client.post.return_value = mock_response - - mock_soup = MagicMock() - mock_soup.css_first.return_value = None - mock_html_parser.return_value = mock_soup - - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'Could not find "Know Your Class and Section" table' in str(exc_info.value) - - -@patch("app.pesu.HTMLParser") -@pytest.mark.asyncio -async def test_get_kycas_no_headers(mock_html_parser, pesu): - """Test that the "Know Your Class and Section" fetch error is raised when headers are empty.""" - client = AsyncMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "
" - client.post.return_value = mock_response - - mock_table = MagicMock() - mock_table.css.return_value = [] - - mock_soup = MagicMock() - mock_soup.css_first.return_value = mock_table - mock_html_parser.return_value = mock_soup - - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'Could not find "Know Your Class and Section" table headers' in str(exc_info.value) - - -@patch("app.pesu.HTMLParser") -@pytest.mark.asyncio -async def test_get_kycas_no_data_row(mock_html_parser, pesu): - """Test that the "Know Your Class and Section" fetch error is raised when no row exists.""" - client = AsyncMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "
PRN
" - client.post.return_value = mock_response - - mock_th = MagicMock() - mock_th.text.return_value = "PRN" - - mock_table = MagicMock() - mock_table.css.return_value = [mock_th] - mock_table.css_first.return_value = None - - mock_soup = MagicMock() - mock_soup.css_first.return_value = mock_table - mock_html_parser.return_value = mock_soup - - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'Could not find "Know Your Class and Section" data row' in str(exc_info.value) - - -@patch("app.pesu.HTMLParser") -@pytest.mark.asyncio -async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): - """Test that the "Know Your Class and Section" fetch error is raised on malformed rows.""" - client = AsyncMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - client.post.return_value = mock_response - - mock_th1 = MagicMock() - mock_th1.text.return_value = "PRN" - mock_th2 = MagicMock() - mock_th2.text.return_value = "SRN" - - mock_td1 = MagicMock() - mock_td1.text.return_value = "PES1201800001" - - mock_row = MagicMock() - mock_row.css.return_value = [mock_td1] - - mock_table = MagicMock() - mock_table.css.return_value = [mock_th1, mock_th2] - mock_table.css_first.return_value = mock_row - - mock_soup = MagicMock() - mock_soup.css_first.return_value = mock_table - mock_html_parser.return_value = mock_soup - - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'Mismatch between "Know Your Class and Section" table headers' in str(exc_info.value) - - -@patch("app.pesu.HTMLParser") -@pytest.mark.asyncio -async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): - """Test that the "Know Your Class and Section" fetch error is raised on unknown headers.""" - client = AsyncMock() - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "" - client.post.return_value = mock_response - - mock_th = MagicMock() - mock_th.text.return_value = "UnknownHeader" - - mock_td = MagicMock() - mock_td.text.return_value = "some_value" - - mock_row = MagicMock() - mock_row.css.return_value = [mock_td] - - mock_table = MagicMock() - mock_table.css.return_value = [mock_th] - mock_table.css_first.return_value = mock_row - - mock_soup = MagicMock() - mock_soup.css_first.return_value = mock_table - mock_html_parser.return_value = mock_soup - - with pytest.raises(KYCASFetchError) as exc_info: - await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - assert 'No "Know Your Class and Section" data could be extracted' in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_get_kycas_success(pesu): - """Test the happy path: successfully parsing "Know Your Class and Section" data.""" - client = AsyncMock() +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_field_filtering(mock_post, pesu): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.text = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PRNSRNNameClassSectionCycleDepartmentBranchInstitute Name
PES2202100984PES2UG21CS310Test UserSem-8Section FNACSE(EC Campus)CSEPES University (Electronic City)
- """ - client.post.return_value = mock_response - - result = await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") - - assert result["prn"] == "PES2202100984" - assert result["srn"] == "PES2UG21CS310" - assert result["name"] == "Test User" - assert result["semester"] == "Sem-8" - assert result["section"] == "Section F" - assert result["cycle"] == "NA" - assert result["department"] == "CSE(EC Campus)" - assert result["branch"] == "CSE" - assert result["instituteName"] == "PES University (Electronic City)" - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") -@pytest.mark.asyncio -async def test_authenticate_passes_kycas_flag(mock_get_kycas, mock_post, mock_get, pesu): - """Test that authenticate calls get_know_your_class_and_section when the flag is set.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - mock_get_kycas.return_value = { - "prn": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "semester": "Sem-6", - "section": "Section A", - "cycle": "NA", - "department": "CSE(RR Campus)", - "branch": "CSE", - "instituteName": "PES University", - } - - result = await pesu.authenticate("testuser", "testpass", profile=False, know_your_class_and_section=True) - - assert result["status"] is True - assert result["knowYourClassAndSection"]["semester"] == "Sem-6" - mock_get_kycas.assert_called_once() - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@pytest.mark.asyncio -async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): - """Test that "Know Your Class and Section" data is NOT returned when not requested.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - result = await pesu.authenticate("user", "pass", know_your_class_and_section=False) - assert result["status"] is True - assert "knowYourClassAndSection" not in result - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") -@pytest.mark.asyncio -async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu): - """Test that "Know Your Class and Section" data is returned when requested.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - mock_get_kycas.return_value = { - "prn": "PES1201800001", + mock_response.json.return_value = { + "login": "SUCCESS", + "userId": "12345", + "loginId": "PES1201800001", "srn": "PES1UG19CS001", "name": "John Doe", - "semester": "Sem-6", - "section": "Section A", - "cycle": "NA", - "department": "CSE(RR Campus)", - "branch": "CSE", - "instituteName": "PES University", - } - - result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) - - assert result["status"] is True - assert "knowYourClassAndSection" in result - assert result["knowYourClassAndSection"]["prn"] == "PES1201800001" - assert result["knowYourClassAndSection"]["instituteName"] == "PES University" - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") -@pytest.mark.asyncio -async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post, mock_get, pesu): - """Test that "Know Your Class and Section" data is filtered when field filtering is enabled.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - mock_get_kycas.return_value = { - "prn": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "semester": "Sem-6", - "section": "Section A", - "cycle": "NA", - "department": "CSE(RR Campus)", - "branch": "CSE", - "instituteName": "PES University", - } - - result = await pesu.authenticate( - "user", - "pass", - know_your_class_and_section=True, - fields=["name", "semester"], - ) - - assert result["status"] is True - kycas = result["knowYourClassAndSection"] - assert "name" in kycas - assert "semester" in kycas - assert "prn" not in kycas - assert "branch" not in kycas - assert "instituteName" not in kycas - - -@patch("app.pesu.httpx.AsyncClient.get") -@patch("app.pesu.httpx.AsyncClient.post") -@patch("app.pesu.PESUAcademy.get_profile_information") -@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") -@pytest.mark.asyncio -async def test_authenticate_with_both_profile_and_kycas( - mock_get_kycas, mock_get_profile, mock_post, mock_get, pesu -): - """Test requesting both profile and "Know Your Class and Section" data simultaneously.""" - mock_get_response = AsyncMock() - mock_get_response.text = '' - mock_get.return_value = mock_get_response - - mock_post_response = AsyncMock() - mock_post_response.text = '' - mock_post.return_value = mock_post_response - - mock_get_profile.return_value = { - "name": "John Doe", - "prn": "PES1201800001", + "phone": "9876543210", "email": "john@example.com", + "program": "Bachelor of Technology", + "branch": "CSE", + "className": "B.Tech-CSE-Sem_4", + "sectionName": "A", + "batchClass": "B.Tech CSE - 4th Semester" } - mock_get_kycas.return_value = { - "prn": "PES1201800001", - "semester": "Sem-6", - "section": "Section A", - } + mock_post.return_value = mock_response result = await pesu.authenticate( - "user", "pass", profile=True, know_your_class_and_section=True + "user", "pass", profile=True, know_your_class_and_section=True, fields=["name", "email"] ) assert result["status"] is True assert "profile" in result - assert "knowYourClassAndSection" in result - assert result["profile"]["name"] == "John Doe" - assert result["knowYourClassAndSection"]["semester"] == "Sem-6" - -def test_kycas_header_to_key_map_is_dict(): - """Test that the "Know Your Class and Section" header map has expected keys.""" - kmap = PESUAcademy.KYCAS_HEADER_TO_KEY_MAP - assert isinstance(kmap, dict) - assert "PRN" in kmap - assert "SRN" in kmap - assert "Name" in kmap - assert "Class" in kmap - assert kmap["Class"] == "semester" - assert "Section" in kmap - assert "Cycle" in kmap - assert "Department" in kmap - assert "Branch" in kmap - assert "Institute Name" in kmap - - -def test_default_fields_includes_kycas_relevant_fields(): - """Test that DEFAULT_FIELDS includes fields relevant to "Know Your Class and Section" filtering.""" - fields = PESUAcademy.DEFAULT_FIELDS - assert "semester" in fields - assert "cycle" in fields - assert "department" in fields - assert "instituteName" in fields - - -@pytest.mark.asyncio -@patch("app.pesu.PESUAcademy._fetch_new_client_with_csrf_token") -async def test_prefetch_client_closes_old_client_on_second_call(mock_fetch, pesu): - old_client = AsyncMock() - new_client = AsyncMock() - mock_fetch.side_effect = [ - (old_client, "token-1"), - (new_client, "token-2"), - ] - - await pesu.prefetch_client_with_csrf_token() - await pesu.prefetch_client_with_csrf_token() - - old_client.aclose.assert_awaited_once() + assert "name" in result["profile"] + assert "email" in result["profile"] + assert "prn" not in result["profile"] diff --git a/uv.lock b/uv.lock index 26d88a0..cd8daa0 100644 --- a/uv.lock +++ b/uv.lock @@ -717,7 +717,6 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "pydantic" }, - { name = "selectolax" }, { name = "uvicorn" }, ] @@ -743,7 +742,6 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.136.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.13.4" }, - { name = "selectolax", specifier = ">=0.4.9" }, { name = "uvicorn", specifier = ">=0.47.0" }, ] @@ -1137,50 +1135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] -[[package]] -name = "selectolax" -version = "0.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/46/b2956203e943c6417ff7da9ad9bcb5860d60372de6e05934af1b3f0a4950/selectolax-0.4.9.tar.gz", hash = "sha256:15c3de54f75d0d13726fee140bbfec8805050a3d34d298f2e9c05c9da1b87e7a", size = 4879844, upload-time = "2026-05-15T08:59:12.802Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/a9/ef84d5879bdc25ee830e57d030b559bc9e86e9d8f25f6c315511a39c5a67/selectolax-0.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee28b8f1326e3504a055a8f8a894e1180f5d3522f61391670c8b2d5e768cbc2", size = 2241162, upload-time = "2026-05-15T08:57:55.593Z" }, - { url = "https://files.pythonhosted.org/packages/40/1d/22554d2ba0d818ba07402dbab9561ff3b438fb8272727e7dfd21e6cad926/selectolax-0.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:668e243607d8bc2d130283506333f63b65bfba83bb32a59cf89f634031f02e0d", size = 2292306, upload-time = "2026-05-15T08:57:57.838Z" }, - { url = "https://files.pythonhosted.org/packages/83/5c/8ef75e1ee59df6f640f79fcf7abc2df012a67587ce0890e5be96e06276ca/selectolax-0.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9451274ccdb46f691f90538e2ede26e67d20f1ab17aa25083db4983208451609", size = 2374496, upload-time = "2026-05-15T08:57:59.69Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f2/bd9e07a8e51b8f4ddcfe4b2f6bbd7100d763d784910eebe15e48bfa813db/selectolax-0.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c37ae18495338320064920144185e3a434d780ddc8654a44f2c611b2e1aa291", size = 2421157, upload-time = "2026-05-15T08:58:01.695Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/8c715ef0e58cf7a38cd91610a45c19cdb78314584881eaf1499198f69095/selectolax-0.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77fd2ab190a5a13ce1c3f616538f325bd0f51760c924c3244f27897d23f0817a", size = 2378875, upload-time = "2026-05-15T08:58:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/7e/f9/55ac766dcf574e5f590fc0774486f7bd52e6e5c08c364f08cc18626533c1/selectolax-0.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:428bbfc0375607dc07ef9086ff6c7fb8e6af051876409e3fcac7d8c856b0fa91", size = 2441299, upload-time = "2026-05-15T08:58:05.288Z" }, - { url = "https://files.pythonhosted.org/packages/c8/06/d8d1a0bfb17ec697904bd33be72d9d29e322359f167aaf111e3ce9026131/selectolax-0.4.9-cp312-cp312-win32.whl", hash = "sha256:7d1afdd14451d7a0d9e095e28df2af11ea2066821170aaca1d7db0e56a44f5dc", size = 1763258, upload-time = "2026-05-15T08:58:06.935Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f5/0727cae946c6dc387f683357c3a48e3f39443f3f0b076513eb4bc4766d3f/selectolax-0.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:592bba3ea66658b2080a6978839887eb1a890d8244441f64b9baf0ca74ed7918", size = 1859646, upload-time = "2026-05-15T08:58:08.417Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/f6995eedda5f3b591d57ab1cf964de0dca01cf77e11ce8433ca9bafc11bd/selectolax-0.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:9ececc7acdb4d16d217d83a55c41e0f4f74fb0e74858005a542e7bd88090aa0d", size = 1809708, upload-time = "2026-05-15T08:58:09.908Z" }, - { url = "https://files.pythonhosted.org/packages/b3/aa/cf3ef1ae4f9a53f53a0add5dcf7c5b241f56f9a778feb7c13cf65e73331b/selectolax-0.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1ca6686d0199ca3ef2c919421116d9d34f56964e2162a0d8757a4e9a7bdfb4a", size = 2240742, upload-time = "2026-05-15T08:58:11.596Z" }, - { url = "https://files.pythonhosted.org/packages/da/81/16ff9754e720769fe3f723baee9e207b2f7b7cf2612d509a9ae84b357ec5/selectolax-0.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f49b7391544f60cc891587bcc504daed4995be2b02af9c6e3054a564a1b25d0", size = 2291271, upload-time = "2026-05-15T08:58:13.057Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c0/b9dd13726bb13a6d439c1fef14d70345da9d15155f058edf9bb124952cd2/selectolax-0.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6833df2a2f74cd89872a2915f5ba6b9e56e4bed31c0b3e26b593ca086f10d298", size = 2373955, upload-time = "2026-05-15T08:58:14.983Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d6/aad9d222d2599bad16f1ee936ccddec3e5e8299a140ea56af2359ed72631/selectolax-0.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:010ff30cc2266074801d92fa717a3c333cc1cf9c98b699a46fc649a9707acc69", size = 2420661, upload-time = "2026-05-15T08:58:16.437Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/07b6190d0b20627880c612450d1211ff22cfc9ad1e845882846b0f97be97/selectolax-0.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:395261e997f0d1d9d0232cf9ba7d69f46229e027ad7eb3ad0afba57c8eec7413", size = 2378677, upload-time = "2026-05-15T08:58:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/de/70/a2592179765f948146952fbea67ccf9b8e32f125847bf30632b0a8574af5/selectolax-0.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:19ee1f7de842be9dcdae7c7d27b01c91a0a717a06b136984234bc5b3048fc3d4", size = 2440935, upload-time = "2026-05-15T08:58:20.129Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/4d8ceecdb2265ec3a2e64bef83e8a2d96b7c8d9b3505dc504b02be64c684/selectolax-0.4.9-cp313-cp313-win32.whl", hash = "sha256:2cd98512c3de597a6b5db9bd90d10b35244c4ba7b99c1149874111f453ae82d2", size = 1763205, upload-time = "2026-05-15T08:58:21.663Z" }, - { url = "https://files.pythonhosted.org/packages/28/23/9ac462b19e296a426e7b3fecc2ce65cf3c2db6d405653a96a0a978e1ae44/selectolax-0.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:35097ce93a954a95ac82d930758cfc32c03421c0f173de7185e6381fbd00be28", size = 1861512, upload-time = "2026-05-15T08:58:23.792Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f9/cda79bdea85a231cc1c9aafb0c21fda0d56b056b67b1e571c26a5c39f832/selectolax-0.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:bca470e8f6bf6b4b41f841e87fcc729c9c2fbed20850ef4bb1b7b9055c1f8a55", size = 1809709, upload-time = "2026-05-15T08:58:25.623Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/fe04c5466577bd23433f5ea8de16a3e1aa273b547616a6141591641c109c/selectolax-0.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b65f589b8e613089e77b6f735d956f6690021c66acaba0973df0e59b7fb6ab22", size = 2256543, upload-time = "2026-05-15T08:58:27.333Z" }, - { url = "https://files.pythonhosted.org/packages/53/aa/95211bc61a84d2f42678791654bde6a3526520f423c1450b735ef54bcf8d/selectolax-0.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1a2a70a02960efc7112a99b81031fb60bca557e62f99806dd3a6354e6c888e3", size = 2308274, upload-time = "2026-05-15T08:58:28.961Z" }, - { url = "https://files.pythonhosted.org/packages/57/c7/f1c63a427346f27ca9a94c63f5f6f83a0130e011f7d6e0bd44a9e073cb31/selectolax-0.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85c463dbd4f1a5b9a1219ea7895ed3307231cb4edcf46e60de99dde93d9dc65b", size = 2373791, upload-time = "2026-05-15T08:58:30.462Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e5/a86685d5bf6ae5d8d1068247aae9a64e5b455b2ffe0d7178d1d780fbcbf3/selectolax-0.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:432dca308603fcb39600fcbf9e02d8917596fde2d7b0fd752d437953bbb83ff0", size = 2419455, upload-time = "2026-05-15T08:58:32.07Z" }, - { url = "https://files.pythonhosted.org/packages/3b/01/14b5a9a425438d8a40c97b08d3221dd2315470ac6edf586abe1c49f3f8b0/selectolax-0.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:25da6d4afec67efcb5b9f228a7f318163e65bc7f4956448cfc994b45894a062f", size = 2394963, upload-time = "2026-05-15T08:58:33.748Z" }, - { url = "https://files.pythonhosted.org/packages/90/f2/6b0b7434b850415adbe050e82d20a6f119dd05c912022a9964b4db47b880/selectolax-0.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:718872445d44b58b0e6bc029993ac79396b5588b13d0c12c8b585f5a1a490c2d", size = 2458094, upload-time = "2026-05-15T08:58:35.553Z" }, - { url = "https://files.pythonhosted.org/packages/53/0d/3b36c76e478deb6fdc7deb105e99e68a7eecef716611a3ef6776472e3057/selectolax-0.4.9-cp314-cp314-win32.whl", hash = "sha256:2affc47bbe2faff46410ce4edd82e9e6fe6a7ac7a84b4e572c1b22fc2fb1832f", size = 1874907, upload-time = "2026-05-15T08:58:37.162Z" }, - { url = "https://files.pythonhosted.org/packages/f7/8c/d140ed3bb7ab6254628e8078c6c7f42d6f857436548c5a1851d6d8e1ccf1/selectolax-0.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0077fe3d5324a345839c8c9da7ac41b577e13a08266b4a24e7c25e16fb2b6229", size = 1969835, upload-time = "2026-05-15T08:58:39.017Z" }, - { url = "https://files.pythonhosted.org/packages/23/f6/97f68770f73b9d65b3584e12d588fda3e4ee86dcfdcbfb05de7c4c6ccf0c/selectolax-0.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:f214d7c10112c818506e2aa72b309acee76e23b6ff677e91e5fd12fb60176abd", size = 1920477, upload-time = "2026-05-15T08:58:40.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/38/e0f0bdacc94dc868e4f7b542f459a34bb71330b1351cfe177cfa749abfee/selectolax-0.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fb3eef8d522534b59498e2aff67d30b879ffb70961406fc6359336d846409f24", size = 2271971, upload-time = "2026-05-15T08:58:43.055Z" }, - { url = "https://files.pythonhosted.org/packages/99/b0/6fd60519f100051e58f83c604452c997b0ce4e25c279405bdd80ba1c8ef9/selectolax-0.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dd270f3c008eddd9b93d6875b05fda569b9943e94b5c918ce5d06123330d35ec", size = 2317744, upload-time = "2026-05-15T08:58:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/f1/5e/f95857eab0a0e213f46eec42ae74cfe939b8aae507e23af3fa17e111ba0a/selectolax-0.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fe69c7d4a510574746fae68d00016d4ff10cffdba845dfa2a40296534390a91", size = 2378938, upload-time = "2026-05-15T08:58:45.975Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/92f66653993e9b2006dc37c202dbfa0e49803266dead36301461957e6345/selectolax-0.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8720dce153464c1d8172160a2dd96e97dfae0a64534aa2b54eafbe8371ba85b1", size = 2429671, upload-time = "2026-05-15T08:58:47.63Z" }, - { url = "https://files.pythonhosted.org/packages/2a/f2/4d0e8a5b875ca822121a4f3cba66a652379903e2cff1beaace48fa7004db/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:44d07709c7ddc49702af5661d717aaeab469121328ce1131e182bbe98020c504", size = 2404125, upload-time = "2026-05-15T08:58:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/99b4962e5a4171c32ae6747a8d84d5d549a85e9cc48a1f8200e35415acec/selectolax-0.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:919fbb7ba30e9e172913e029e453be68215122bbb3e7e8cedf524e525c24556b", size = 2466287, upload-time = "2026-05-15T08:58:50.889Z" }, - { url = "https://files.pythonhosted.org/packages/71/f4/8a764ebfd95bc1c30d542f32d8eda19e78baa07e2de8f6c355b11de6ee56/selectolax-0.4.9-cp314-cp314t-win32.whl", hash = "sha256:596513c191ec4771357abc21160414e2a0054f064ba543e33e1a7ebd35c96ea6", size = 1923506, upload-time = "2026-05-15T08:58:52.528Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7c/aa021b3ed7c9fce5f16e0a0b4c8897e7970772ba8688754e5813214017d1/selectolax-0.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:008206609f542cbad133570330e51cb4bb666d532b05111914072673de60f33a", size = 2038829, upload-time = "2026-05-15T08:58:53.904Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/525f6a3560d13199d65f1c9f1816ad307d4ed2e1ad05daba08e024fc5364/selectolax-0.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:7ab014959dcf68a0be71484dc87b8d4002756c979836d1d873ba81302b6bffe0", size = 1943672, upload-time = "2026-05-15T08:58:55.808Z" }, -] - [[package]] name = "six" version = "1.17.0" From 1cdc6057d247470850923ddaa1e640de40601eda Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:26:22 +0530 Subject: [PATCH 2/7] feat: fetch nameAsInSSLC from ISA results as full name with fallback * feat: add _fetch_name_as_in_sslc to retrieve the student's official full name from ISA results * refactor: add _parse_sslc_name helper to resolve Ruff C901 complexity and ANN401 lint errors * feat: implement fallback to raw login display name for new/first-year students who do not have results declared * test: add unit tests covering all branch paths and error conditions of full-name resolution * test: achieve 99% overall test coverage, satisfying the 95% fail-under requirement --- app/pesu.py | 83 ++++++++++++++++++++++++++++- tests/unit/test_pesu.py | 114 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 7 deletions(-) diff --git a/app/pesu.py b/app/pesu.py index 026eb62..49d49a3 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -82,6 +82,76 @@ def __init__(self) -> None: """Initialize the PESUAcademy class.""" pass + def _parse_sslc_name(self, res_data: object) -> str | None: + """Parse nameAsInSSLC from the ISA marks response JSON.""" + if not isinstance(res_data, dict): + return None + for marks in res_data.values(): + if not isinstance(marks, list): + continue + for mark in marks: + if not isinstance(mark, dict): + continue + name_sslc = mark.get("NameAsInSSLC") + if name_sslc and name_sslc.strip(): + return name_sslc.strip() + return None + + async def _fetch_name_as_in_sslc(self, client: httpx.AsyncClient, token: str, user_id: str) -> str | None: + """Fetch the official name (nameAsInSSLC) from ISA results.""" + dispatcher_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" + headers = {"mobileappauthenticationtoken": token} + + # 1. Fetch ISA semesters + sem_payload = { + "action": "6", + "mode": "5", + "userId": user_id, + "randomNum": "0.5", + "whichObjectId": "clickHome_footer_myresults", + "title": "ISA Results", + "serverMode": "0", + "redirectValue": "redirect:/a/ad", + } + try: + sem_resp = await client.post(dispatcher_url, data=sem_payload, headers=headers) + if sem_resp.status_code != 200: + return None + sem_data = sem_resp.json() + if isinstance(sem_data, str): + sem_data = json.loads(sem_data) + if not isinstance(sem_data, list) or len(sem_data) == 0: + return None + + # Get the first/current semester + semester = sem_data[0] + batch_class_id = semester.get("BatchClassId") + class_batch_section_id = semester.get("ClassBatchSectionId") + if batch_class_id is None or class_batch_section_id is None: + return None + + # 2. Fetch ISA results for that semester + results_payload = { + "action": "6", + "mode": "9", + "userId": user_id, + "randomNum": "0.5", + "batchClassId": str(batch_class_id), + "classBatchSectionId": str(class_batch_section_id), + "fetchId": f"{batch_class_id}-{class_batch_section_id}", + } + res_resp = await client.post(dispatcher_url, data=results_payload, headers=headers) + if res_resp.status_code != 200: + return None + res_data = res_resp.json() + if isinstance(res_data, str): + res_data = json.loads(res_data) + + return self._parse_sslc_name(res_data) + except Exception: + pass + return None + def _map_data( self, data: dict[str, Any], @@ -90,6 +160,7 @@ def _map_data( know_your_class_and_section: bool, fields: list[str], field_filtering: bool, + name_sslc: str | None = None, ) -> dict[str, Any]: """Map the profile and class/section data from the response JSON.""" prn = data.get("loginId") @@ -113,9 +184,10 @@ def _map_data( result = {"status": True, "message": "Login successful."} # Fetch the profile information if profile details are requested + name = name_sslc or data.get("name") if profile: profile_dict = { - "name": data.get("name"), + "name": name, "prn": prn, "srn": srn, "program": program, @@ -137,7 +209,7 @@ def _map_data( kycas_dict = { "prn": prn, "srn": srn, - "name": data.get("name"), + "name": name, "semester": semester_val, "section": f"Section {data.get('sectionName')}" if data.get("sectionName") else None, "cycle": "NA", @@ -238,6 +310,12 @@ async def authenticate( if not profile and not know_your_class_and_section: return {"status": True, "message": "Login successful."} + token = response.headers.get("mobileappauthenticationtoken") or "" + user_id = str(data.get("userId") or "") + name_sslc = None + if token and user_id: + name_sslc = await self._fetch_name_as_in_sslc(client, token, user_id) + # Map the parsed response JSON to return format return self._map_data( data=data, @@ -246,4 +324,5 @@ async def authenticate( know_your_class_and_section=know_your_class_and_section, fields=fields, field_filtering=field_filtering, + name_sslc=name_sslc, ) diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 11edaee..09acbec 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -12,9 +12,9 @@ def pesu(): @pytest.mark.asyncio @patch("app.pesu.httpx.AsyncClient.post") async def test_authenticate_success(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { + mock_login = MagicMock() + mock_login.status_code = 200 + mock_login.json.return_value = { "login": "SUCCESS", "userId": "12345", "loginId": "PES1201800001", @@ -28,14 +28,38 @@ async def test_authenticate_success(mock_post, pesu): "sectionName": "A", "batchClass": "B.Tech CSE - 4th Semester" } - mock_post.return_value = mock_response + + mock_sems = MagicMock() + mock_sems.status_code = 200 + mock_sems.json.return_value = [ + { + "BatchClassId": 123, + "ClassBatchSectionId": 456, + "ClassName": "B.Tech-CSE-Sem_4" + } + ] + + mock_marks = MagicMock() + mock_marks.status_code = 200 + mock_marks.json.return_value = { + "marks_list": [ + { + "NameAsInSSLC": "John Doe FullName", + "subjectId": 99, + "subjectCode": "CS101", + "subjectName": "Computer Science" + } + ] + } + + mock_post.side_effect = [mock_login, mock_sems, mock_marks] result = await pesu.authenticate("user", "pass", profile=True, know_your_class_and_section=True) assert result["status"] is True assert result["message"] == "Login successful." assert "profile" in result - assert result["profile"]["name"] == "John Doe" + assert result["profile"]["name"] == "John Doe FullName" assert result["profile"]["prn"] == "PES1201800001" assert result["profile"]["semester"] == "Sem-4" assert result["profile"]["section"] == "A" @@ -48,6 +72,58 @@ async def test_authenticate_success(mock_post, pesu): assert result["knowYourClassAndSection"]["section"] == "Section A" +@pytest.mark.asyncio +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_sslc_name_errors(mock_post, pesu): + mock_login = MagicMock() + mock_login.status_code = 200 + mock_login.json.return_value = { + "login": "SUCCESS", + "userId": "12345", + "loginId": "PES1201800001", + "name": "John Doe", + } + + # 1. Test when semester endpoint fails with non-200 + mock_sems_fail = MagicMock() + mock_sems_fail.status_code = 500 + + mock_post.side_effect = [mock_login, mock_sems_fail] + result = await pesu.authenticate("user", "pass", profile=True) + assert result["profile"]["name"] == "John Doe" + + # 2. Test when semester endpoint returns nested JSON string and marks fail + mock_sems_str = MagicMock() + mock_sems_str.status_code = 200 + mock_sems_str.json.return_value = '[{"BatchClassId": 123, "ClassBatchSectionId": 456}]' + + mock_marks_fail = MagicMock() + mock_marks_fail.status_code = 500 + + mock_post.side_effect = [mock_login, mock_sems_str, mock_marks_fail] + result = await pesu.authenticate("user", "pass", profile=True) + assert result["profile"]["name"] == "John Doe" + + # 3. Test when marks endpoint returns nested JSON string + mock_sems_valid = MagicMock() + mock_sems_valid.status_code = 200 + mock_sems_valid.json.return_value = [ + { + "BatchClassId": 123, + "ClassBatchSectionId": 456 + } + ] + + mock_marks_str = MagicMock() + mock_marks_str.status_code = 200 + mock_marks_str.json.return_value = '{"marks_list": [{"NameAsInSSLC": "Nested String Name"}]}' + + mock_post.side_effect = [mock_login, mock_sems_valid, mock_marks_str] + result = await pesu.authenticate("user", "pass", profile=True) + assert result["profile"]["name"] == "Nested String Name" + + + @pytest.mark.asyncio @patch("app.pesu.httpx.AsyncClient.post") async def test_authenticate_success_no_details(mock_post, pesu): @@ -133,3 +209,31 @@ async def test_authenticate_field_filtering(mock_post, pesu): assert "name" in result["profile"] assert "email" in result["profile"] assert "prn" not in result["profile"] + + +def test_get_semester_from_class_branches(): + from app.pesu import _get_semester_from_class + assert _get_semester_from_class("4th Sem", None) == "Sem-4" + assert _get_semester_from_class("B.Tech CSE 2", None) == "Sem-2" + assert _get_semester_from_class(None, None) is None + assert _get_semester_from_class("NoSemesterHere", "") is None + + +def test_parse_sslc_name_non_dict(pesu): + assert pesu._parse_sslc_name(None) is None + assert pesu._parse_sslc_name([]) is None + assert pesu._parse_sslc_name({"marks": "invalid"}) is None + assert pesu._parse_sslc_name({"marks": [None]}) is None + + +@pytest.mark.asyncio +@patch("app.pesu.httpx.AsyncClient.post") +async def test_authenticate_invalid_json_responses(mock_post, pesu): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_post.return_value = mock_response + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Invalid JSON response" in str(exc_info.value) From 38ea5d6446b6faa0ed3c43e35dcc7b3c13db1472 Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:11:57 +0530 Subject: [PATCH 3/7] fix: bringing back test_authenticate_unit.py and refactoring unit tests test_authenticate_unit.py was accidentally removed during a LLM-assisted refactor of the test suite. The file has been restored and updated alongside the other unit tests to align with the current mobile-API implementation. - Restore test_authenticate_unit.py with updated authenticate() tests - Refactor test_pesu.py to cover _parse_sslc_name, _fetch_name_as_in_sslc, _map_data, and authenticate() against the new mobile-API flow - Update test_app_unit.py to remove stale _csrf_token_refresh_loop import --- tests/conftest.py | 13 - tests/unit/test_app_unit.py | 70 ++- tests/unit/test_authenticate_unit.py | 165 ++++++ tests/unit/test_pesu.py | 806 +++++++++++++++++++++------ 4 files changed, 863 insertions(+), 191 deletions(-) create mode 100644 tests/unit/test_authenticate_unit.py diff --git a/tests/conftest.py b/tests/conftest.py index 6ee4988..08f428f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -import os -import pytest from dotenv import load_dotenv load_dotenv() @@ -13,8 +11,6 @@ def pytest_collection_modifyitems(config, items): "integration": 2, } - has_secrets = os.getenv("TEST_EMAIL") is not None and os.getenv("TEST_PASSWORD") is not None - def sort_key(item): for key, value in priority.items(): if key in str(item.fspath): @@ -22,12 +18,3 @@ def sort_key(item): return 99 items.sort(key=sort_key) - - # Automatically skip secret-required tests if credentials are not configured in the environment - if not has_secrets: - skip_marker = pytest.mark.skip( - reason="Integration/functional tests skipped because TEST_EMAIL or TEST_PASSWORD is not set." - ) - for item in items: - if "secret_required" in item.keywords: - item.add_marker(skip_marker) diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index e4d644c..84982c0 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -1,18 +1,27 @@ -import asyncio +"""Unit tests for app/app.py — FastAPI application layer.""" + from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient from app.app import app, main + + @pytest.fixture def client(): - with TestClient(app, raise_server_exceptions=False) as client: - yield client + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +# --------------------------------------------------------------------------- +# /authenticate — validation & exception handling +# --------------------------------------------------------------------------- @patch("app.app.pesu_academy.authenticate") def test_authenticate_validation_error(mock_authenticate, client, caplog): + """If pesu_academy returns data that fails ResponseModel validation → 500.""" mock_authenticate.return_value = { "status": True, "message": "Login successful", @@ -29,6 +38,7 @@ def test_authenticate_validation_error(mock_authenticate, client, caplog): @patch("app.app.pesu_academy.authenticate") def test_authenticate_general_exception(mock_authenticate, client): + """Unhandled exception from pesu_academy → 500 with generic message.""" mock_authenticate.side_effect = Exception("Test exception") payload = {"username": "testuser", "password": "testpass", "profile": False} response = client.post("/authenticate", json=payload) @@ -36,6 +46,60 @@ def test_authenticate_general_exception(mock_authenticate, client): data = response.json() assert "Internal Server Error" in data["message"] + +# --------------------------------------------------------------------------- +# /authenticate — successful mock response +# --------------------------------------------------------------------------- + + +@patch("app.app.pesu_academy.authenticate") +def test_authenticate_success_mocked(mock_authenticate, client): + """When pesu_academy returns a valid result the endpoint returns 200.""" + mock_authenticate.return_value = { + "status": True, + "message": "Login successful.", + } + payload = {"username": "testuser", "password": "testpass"} + response = client.post("/authenticate", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data["message"] == "Login successful." + assert "timestamp" in data + + +# --------------------------------------------------------------------------- +# /health +# --------------------------------------------------------------------------- + + +def test_health_endpoint(client): + """Health check returns 200 with status=True and message='ok'.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] is True + assert data["message"] == "ok" + assert "timestamp" in data + + +# --------------------------------------------------------------------------- +# /readme +# --------------------------------------------------------------------------- + + +def test_readme_redirect(client): + """README endpoint returns 308 redirect to GitHub.""" + response = client.get("/readme", follow_redirects=False) + assert response.status_code == 308 + assert "github.com" in response.headers["location"] + + +# --------------------------------------------------------------------------- +# main() CLI function +# --------------------------------------------------------------------------- + + @patch("app.app.argparse.ArgumentParser.parse_args") @patch("app.app.logging.basicConfig") @patch("app.app.uvicorn.run") diff --git a/tests/unit/test_authenticate_unit.py b/tests/unit/test_authenticate_unit.py new file mode 100644 index 0000000..c306204 --- /dev/null +++ b/tests/unit/test_authenticate_unit.py @@ -0,0 +1,165 @@ +"""Unit tests for PESUAcademy.authenticate — current mobile-API implementation.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.exceptions.authentication import AuthenticationError +from app.pesu import PESUAcademy + + +@pytest.fixture +def pesu(): + return PESUAcademy() + + +def _make_client(login="SUCCESS", status_code=200, extra_data=None, token="tok", user_id="7"): + """Build a mock httpx.AsyncClient context manager for the authenticate() call.""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + data = { + "login": login, + "userId": int(user_id) if user_id else None, + "loginId": "PES1201800001", + "departmentId": "PES1UG19CS001", + "className": "Sem-4", + "batchClass": None, + "program": "B.Tech.", + "branch": "CSE", + "sectionName": "B", + "email": "test@example.com", + "phone": "9876543210", + "name": "Test User", + } + if extra_data: + data.update(extra_data) + + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.json.return_value = data + mock_response.headers = {"mobileappauthenticationtoken": token} + mock_client.post.return_value = mock_response + return mock_client + + +# --------------------------------------------------------------------------- +# Success paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_authenticate_success_no_profile(pesu): + """Successful login with no profile requested returns only status and message.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client() + result = await pesu.authenticate("user", "pass", profile=False) + + assert result["status"] is True + assert result["message"] == "Login successful." + assert "profile" not in result + + +@pytest.mark.asyncio +async def test_authenticate_success_with_profile(pesu): + """Successful login with profile=True returns profile data.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True, fields=["prn", "name"]) + + assert result["status"] is True + assert "profile" in result + assert "prn" in result["profile"] + assert "name" in result["profile"] + # Field filtering works: branch was not requested + assert "branch" not in result["profile"] + + +@pytest.mark.asyncio +async def test_authenticate_success_with_profile_all_fields(pesu): + """When fields=None all profile fields are returned without filtering.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True, fields=None) + + assert "profile" in result + for key in ["name", "prn", "srn", "branch", "semester", "section", "email", "phone"]: + assert key in result["profile"], f"'{key}' missing from profile" + + +# --------------------------------------------------------------------------- +# Failure paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_authenticate_connection_failure(pesu): + """Network error during POST raises AuthenticationError.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.side_effect = Exception("timeout") + mock_cls.return_value = mock_client + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Connection failed" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_non_200_status(pesu): + """Non-200 HTTP status raises AuthenticationError.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client(status_code=503) + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "status code 503" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_login_failure_with_error_message(pesu): + """login != SUCCESS and errorMessage present → AuthenticationError with that message.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client( + login="FAILURE", + extra_data={"errorMessage": "Invalid username or password"}, + ) + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "wrongpass") + assert "Invalid username or password" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_login_failure_no_error_message(pesu): + """login != SUCCESS without errorMessage → fallback error message used.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_client(login="FAILURE", extra_data={"errorMessage": None}) + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("baduser", "badpass") + assert "baduser" in str(exc_info.value) or "Invalid username" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_invalid_json_raises_error(pesu): + """Unparseable JSON response raises AuthenticationError.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = Exception("decode error") + mock_client.post.return_value = mock_response + mock_cls.return_value = mock_client + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Invalid JSON response" in str(exc_info.value) diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 09acbec..06377d1 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -1,239 +1,695 @@ +"""Unit tests for app/pesu.py — PESUAcademy class (mobile-API implementation).""" + from unittest.mock import AsyncMock, MagicMock, patch + import pytest -import httpx from app.exceptions.authentication import AuthenticationError -from app.pesu import PESUAcademy +from app.pesu import BRANCH_MAPPING, PROGRAM_MAPPING, PESUAcademy, _get_semester_from_class + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- @pytest.fixture def pesu(): return PESUAcademy() -@pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_success(mock_post, pesu): - mock_login = MagicMock() - mock_login.status_code = 200 - mock_login.json.return_value = { - "login": "SUCCESS", - "userId": "12345", - "loginId": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "phone": "9876543210", - "email": "john@example.com", - "program": "Bachelor of Technology", - "branch": "CSE", - "className": "B.Tech-CSE-Sem_4", - "sectionName": "A", - "batchClass": "B.Tech CSE - 4th Semester" - } - mock_sems = MagicMock() - mock_sems.status_code = 200 - mock_sems.json.return_value = [ - { - "BatchClassId": 123, - "ClassBatchSectionId": 456, - "ClassName": "B.Tech-CSE-Sem_4" - } + +# --------------------------------------------------------------------------- +# _get_semester_from_class (module-level helper) +# --------------------------------------------------------------------------- + + +def test_get_semester_from_class_sem_prefix(): + assert _get_semester_from_class("Semester 3", None) == "Sem-3" + assert _get_semester_from_class("Sem-5", None) == "Sem-5" + assert _get_semester_from_class("Sem 2", None) == "Sem-2" + + +def test_get_semester_from_class_suffix(): + assert _get_semester_from_class("4th Sem", None) == "Sem-4" + assert _get_semester_from_class("6 Sem", None) == "Sem-6" + + +def test_get_semester_from_class_bare_digit(): + assert _get_semester_from_class("7", None) == "Sem-7" + + +def test_get_semester_from_class_falls_back_to_batch_class(): + assert _get_semester_from_class(None, "Sem-8") == "Sem-8" + assert _get_semester_from_class(None, "3") == "Sem-3" + + +def test_get_semester_from_class_both_none(): + assert _get_semester_from_class(None, None) is None + + +def test_get_semester_from_class_class_name_preferred(): + # class_name wins over batch_class + assert _get_semester_from_class("Sem-2", "Sem-8") == "Sem-2" + + +# --------------------------------------------------------------------------- +# PROGRAM_MAPPING / BRANCH_MAPPING constants +# --------------------------------------------------------------------------- + + +def test_program_mapping_keys(): + assert "B.Tech." in PROGRAM_MAPPING + assert "M.Tech." in PROGRAM_MAPPING + assert PROGRAM_MAPPING["B.Tech."] == "Bachelor of Technology" + assert PROGRAM_MAPPING["MBA"] == "Master of Business Administration" + + +def test_branch_mapping_keys(): + assert "CSE" in BRANCH_MAPPING + assert "ECE" in BRANCH_MAPPING + assert BRANCH_MAPPING["CSE"] == "Computer Science and Engineering" + + +# --------------------------------------------------------------------------- +# PESUAcademy.DEFAULT_FIELDS +# --------------------------------------------------------------------------- + + +def test_default_fields_is_list(): + assert isinstance(PESUAcademy.DEFAULT_FIELDS, list) + expected = [ + "name", "prn", "srn", "program", "branch", "semester", + "section", "email", "phone", "campusCode", "campus", + "cycle", "department", "instituteName", ] + for field in expected: + assert field in PESUAcademy.DEFAULT_FIELDS, f"'{field}' missing from DEFAULT_FIELDS" + + +def test_default_fields_includes_kycas_relevant_fields(): + """DEFAULT_FIELDS must include fields relevant to KYCAS filtering.""" + fields = PESUAcademy.DEFAULT_FIELDS + assert "semester" in fields + assert "cycle" in fields + assert "department" in fields + assert "instituteName" in fields + + +# --------------------------------------------------------------------------- +# PESUAcademy._parse_sslc_name +# --------------------------------------------------------------------------- + + +def test_parse_sslc_name_returns_none_for_non_dict(pesu): + assert pesu._parse_sslc_name(None) is None + assert pesu._parse_sslc_name("string") is None + assert pesu._parse_sslc_name([]) is None + + +def test_parse_sslc_name_returns_none_when_list_has_no_dicts(pesu): + data = {"subject": ["not a dict", 42]} + assert pesu._parse_sslc_name(data) is None + + +def test_parse_sslc_name_returns_none_when_name_missing(pesu): + data = {"subject": [{"Score": 90}]} + assert pesu._parse_sslc_name(data) is None + + +def test_parse_sslc_name_returns_none_when_name_empty(pesu): + data = {"subject": [{"NameAsInSSLC": " "}]} + assert pesu._parse_sslc_name(data) is None + - mock_marks = MagicMock() - mock_marks.status_code = 200 - mock_marks.json.return_value = { - "marks_list": [ - { - "NameAsInSSLC": "John Doe FullName", - "subjectId": 99, - "subjectCode": "CS101", - "subjectName": "Computer Science" - } - ] +def test_parse_sslc_name_returns_stripped_name(pesu): + data = {"subject": [{"NameAsInSSLC": " John Doe "}]} + assert pesu._parse_sslc_name(data) == "John Doe" + + +def test_parse_sslc_name_picks_first_non_empty(pesu): + data = { + "sub1": [{"NameAsInSSLC": " "}], + "sub2": [{"NameAsInSSLC": "Jane Doe"}], } + result = pesu._parse_sslc_name(data) + assert result == "Jane Doe" - mock_post.side_effect = [mock_login, mock_sems, mock_marks] - result = await pesu.authenticate("user", "pass", profile=True, know_your_class_and_section=True) +# --------------------------------------------------------------------------- +# PESUAcademy._fetch_name_as_in_sslc +# --------------------------------------------------------------------------- - assert result["status"] is True - assert result["message"] == "Login successful." - assert "profile" in result - assert result["profile"]["name"] == "John Doe FullName" - assert result["profile"]["prn"] == "PES1201800001" - assert result["profile"]["semester"] == "Sem-4" - assert result["profile"]["section"] == "A" - assert result["profile"]["campus"] == "RR" - assert result["profile"]["campusCode"] == 1 - assert "knowYourClassAndSection" in result - assert result["knowYourClassAndSection"]["prn"] == "PES1201800001" - assert result["knowYourClassAndSection"]["semester"] == "Sem-4" - assert result["knowYourClassAndSection"]["section"] == "Section A" +@pytest.mark.asyncio +async def test_fetch_name_as_in_sslc_returns_none_on_http_error(pesu): + client = AsyncMock() + client.post.side_effect = Exception("Connection error") + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_sslc_name_errors(mock_post, pesu): - mock_login = MagicMock() - mock_login.status_code = 200 - mock_login.json.return_value = { - "login": "SUCCESS", - "userId": "12345", - "loginId": "PES1201800001", - "name": "John Doe", - } +async def test_fetch_name_as_in_sslc_returns_none_on_non_200_sem(pesu): + client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 500 + client.post.return_value = mock_resp + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None - # 1. Test when semester endpoint fails with non-200 - mock_sems_fail = MagicMock() - mock_sems_fail.status_code = 500 - - mock_post.side_effect = [mock_login, mock_sems_fail] - result = await pesu.authenticate("user", "pass", profile=True) - assert result["profile"]["name"] == "John Doe" - - # 2. Test when semester endpoint returns nested JSON string and marks fail - mock_sems_str = MagicMock() - mock_sems_str.status_code = 200 - mock_sems_str.json.return_value = '[{"BatchClassId": 123, "ClassBatchSectionId": 456}]' - - mock_marks_fail = MagicMock() - mock_marks_fail.status_code = 500 - - mock_post.side_effect = [mock_login, mock_sems_str, mock_marks_fail] - result = await pesu.authenticate("user", "pass", profile=True) - assert result["profile"]["name"] == "John Doe" - - # 3. Test when marks endpoint returns nested JSON string - mock_sems_valid = MagicMock() - mock_sems_valid.status_code = 200 - mock_sems_valid.json.return_value = [ - { - "BatchClassId": 123, - "ClassBatchSectionId": 456 - } - ] - mock_marks_str = MagicMock() - mock_marks_str.status_code = 200 - mock_marks_str.json.return_value = '{"marks_list": [{"NameAsInSSLC": "Nested String Name"}]}' +@pytest.mark.asyncio +async def test_fetch_name_as_in_sslc_returns_none_when_sem_data_empty(pesu): + client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [] + client.post.return_value = mock_resp + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None - mock_post.side_effect = [mock_login, mock_sems_valid, mock_marks_str] - result = await pesu.authenticate("user", "pass", profile=True) - assert result["profile"]["name"] == "Nested String Name" +@pytest.mark.asyncio +async def test_fetch_name_as_in_sslc_returns_none_when_sem_data_not_list(pesu): + client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"not": "a list"} + client.post.return_value = mock_resp + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_success_no_details(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "login": "SUCCESS", - "userId": "12345", +async def test_fetch_name_as_in_sslc_returns_none_when_ids_missing(pesu): + client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [{"BatchClassId": None, "ClassBatchSectionId": None}] + client.post.return_value = mock_resp + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None + + +@pytest.mark.asyncio +async def test_fetch_name_as_in_sslc_returns_none_on_non_200_results(pesu): + client = AsyncMock() + sem_resp = MagicMock() + sem_resp.status_code = 200 + sem_resp.json.return_value = [{"BatchClassId": 1, "ClassBatchSectionId": 2}] + + results_resp = MagicMock() + results_resp.status_code = 403 + results_resp.json.return_value = {} + + client.post.side_effect = [sem_resp, results_resp] + result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") + assert result is None + + +@pytest.mark.asyncio +async def test_fetch_name_as_in_sslc_happy_path(pesu): + client = AsyncMock() + sem_resp = MagicMock() + sem_resp.status_code = 200 + sem_resp.json.return_value = [{"BatchClassId": 10, "ClassBatchSectionId": 20}] + + results_resp = MagicMock() + results_resp.status_code = 200 + results_resp.json.return_value = {"Math": [{"NameAsInSSLC": "Alice Smith", "Score": 95}]} + + client.post.side_effect = [sem_resp, results_resp] + result = await pesu._fetch_name_as_in_sslc(client, "tok", "u1") + assert result == "Alice Smith" + + +# --------------------------------------------------------------------------- +# PESUAcademy._map_data +# --------------------------------------------------------------------------- + + +def _sample_data(overrides=None): + base = { + "loginId": "PES1201800001", + "departmentId": "PES1UG19CS001", + "className": "Sem-4", + "batchClass": None, + "program": "B.Tech.", + "branch": "CSE", + "sectionName": "A", + "email": "test@example.com", + "phone": "9876543210", + "name": "Fallback Name", } - mock_post.return_value = mock_response + if overrides: + base.update(overrides) + return base - result = await pesu.authenticate("user", "pass", profile=False, know_your_class_and_section=False) +def test_map_data_status_and_message(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=False, + know_your_class_and_section=False, fields=[], field_filtering=False + ) assert result["status"] is True assert result["message"] == "Login successful." + + +def test_map_data_no_profile_no_kycas(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=False, + know_your_class_and_section=False, fields=[], field_filtering=False + ) assert "profile" not in result assert "knowYourClassAndSection" not in result + + +def test_map_data_profile_included(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False + ) + assert "profile" in result + profile = result["profile"] + assert profile["prn"] == "PES1201800001" + assert profile["srn"] == "PES1UG19CS001" + assert profile["program"] == "Bachelor of Technology" + assert profile["branch"] == "Computer Science and Engineering" + assert profile["semester"] == "Sem-4" + assert profile["section"] == "A" + assert profile["email"] == "test@example.com" + assert profile["phone"] == "9876543210" + assert profile["campusCode"] == 1 + assert profile["campus"] == "RR" + + +def test_map_data_campus_code_ec(pesu): + data = _sample_data({"loginId": "PES2202100001"}) + result = pesu._map_data( + data, "user", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False + ) + assert result["profile"]["campusCode"] == 2 + assert result["profile"]["campus"] == "EC" + + +def test_map_data_campus_code_unknown(pesu): + """For a PRN with an unrecognised campus digit (e.g. PES3…), campus is None + but campusCode still receives the parsed integer from the regex.""" + data = _sample_data({"loginId": "PES3202100001"}) + result = pesu._map_data( + data, "user", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False + ) + # campusCode is set to the parsed digit (3) even when the campus name is unknown + assert result["profile"]["campusCode"] == 3 + assert result["profile"]["campus"] is None + + +def test_map_data_name_sslc_takes_priority(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False, + name_sslc="Official Name" + ) + assert result["profile"]["name"] == "Official Name" + + +def test_map_data_fallback_to_data_name(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False, + name_sslc=None + ) + assert result["profile"]["name"] == "Fallback Name" + + +def test_map_data_profile_field_filtering(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=True, + know_your_class_and_section=False, fields=["name", "email"], field_filtering=True + ) + profile = result["profile"] + assert "name" in profile + assert "email" in profile + assert "prn" not in profile + assert "branch" not in profile + + +def test_map_data_kycas_included(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=False, + know_your_class_and_section=True, fields=[], field_filtering=False + ) + assert "knowYourClassAndSection" in result + kycas = result["knowYourClassAndSection"] + assert kycas["prn"] == "PES1201800001" + assert kycas["srn"] == "PES1UG19CS001" + assert kycas["semester"] == "Sem-4" + assert kycas["section"] == "Section A" + assert kycas["cycle"] == "NA" + assert kycas["instituteName"] == "PES University" + assert kycas["branch"] == "CSE" + assert kycas["department"] == "CSE" + + +def test_map_data_kycas_section_none_when_no_section_name(pesu): + data = _sample_data({"sectionName": None}) + result = pesu._map_data( + data, "user", profile=False, + know_your_class_and_section=True, fields=[], field_filtering=False + ) + assert result["knowYourClassAndSection"]["section"] is None + + +def test_map_data_kycas_field_filtering(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=False, + know_your_class_and_section=True, fields=["name", "semester"], field_filtering=True + ) + kycas = result["knowYourClassAndSection"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "instituteName" not in kycas + + +def test_map_data_both_profile_and_kycas(pesu): + result = pesu._map_data( + _sample_data(), "user", profile=True, + know_your_class_and_section=True, fields=[], field_filtering=False + ) + assert "profile" in result + assert "knowYourClassAndSection" in result + + +def test_map_data_srn_falls_back_to_username_when_department_id_missing(pesu): + data = _sample_data({"departmentId": None}) + result = pesu._map_data( + data, "myusername", profile=True, + know_your_class_and_section=False, fields=[], field_filtering=False + ) + assert result["profile"]["srn"] == "myusername" + + +# --------------------------------------------------------------------------- +# PESUAcademy.authenticate — error paths +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_failed_credentials(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "login": "FAILURE", - "errorMessage": "Invalid username or password, or user does not exist" - } - mock_post.return_value = mock_response +async def test_authenticate_connection_failure_raises_authentication_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.side_effect = Exception("Connection refused") + mock_client_cls.return_value = mock_client - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "wrongpass") - assert "Invalid username or password" in str(exc_info.value) + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Connection failed" in str(exc_info.value) @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_connection_error(mock_post, pesu): - mock_post.side_effect = httpx.RequestError("Connection timed out") +async def test_authenticate_non_200_response_raises_authentication_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Connection failed" in str(exc_info.value) + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "status code 403" in str(exc_info.value) @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_non_200_status(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 500 - mock_post.return_value = mock_response +async def test_authenticate_invalid_json_response_raises_authentication_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = Exception("Invalid JSON") + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Server returned status code 500" in str(exc_info.value) + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Invalid JSON response" in str(exc_info.value) @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_field_filtering(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { +async def test_authenticate_login_not_success_raises_authentication_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "login": "FAILURE", + "errorMessage": "Invalid username or password", + } + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "wrongpass") + assert "Invalid username or password" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_login_not_dict_raises_authentication_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = ["unexpected", "list"] + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + with pytest.raises(AuthenticationError): + await pesu.authenticate("user", "pass") + + +@pytest.mark.asyncio +async def test_authenticate_nested_json_string_parsed(pesu): + """If response.json() returns a string, it should be re-parsed as JSON.""" + import json as _json + + inner = {"login": "FAILURE", "errorMessage": "Nested error"} + + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = _json.dumps(inner) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Nested error" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_authenticate_invalid_nested_json_raises_error(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = "this is not valid { json" + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + with pytest.raises(AuthenticationError) as exc_info: + await pesu.authenticate("user", "pass") + assert "Invalid nested JSON" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# PESUAcademy.authenticate — success paths +# --------------------------------------------------------------------------- + + +def _make_successful_client(overrides=None, token="tok123", user_id="42"): + """Return a mock httpx.AsyncClient context manager for a successful login.""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + data = { "login": "SUCCESS", - "userId": "12345", + "userId": int(user_id) if user_id else None, "loginId": "PES1201800001", - "srn": "PES1UG19CS001", - "name": "John Doe", - "phone": "9876543210", - "email": "john@example.com", - "program": "Bachelor of Technology", + "departmentId": "PES1UG19CS001", + "className": "Sem-4", + "batchClass": None, + "program": "B.Tech.", "branch": "CSE", - "className": "B.Tech-CSE-Sem_4", "sectionName": "A", - "batchClass": "B.Tech CSE - 4th Semester" + "email": "test@example.com", + "phone": "9876543210", + "name": "Test User", } - mock_post.return_value = mock_response + if overrides: + data.update(overrides) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = data + mock_response.headers = {"mobileappauthenticationtoken": token} + mock_client.post.return_value = mock_response + return mock_client + + +@pytest.mark.asyncio +async def test_authenticate_success_no_profile_no_kycas(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + result = await pesu.authenticate("user", "pass") + assert result["status"] is True + assert result["message"] == "Login successful." + assert "profile" not in result + assert "knowYourClassAndSection" not in result - result = await pesu.authenticate( - "user", "pass", profile=True, know_your_class_and_section=True, fields=["name", "email"] - ) +@pytest.mark.asyncio +async def test_authenticate_success_with_profile(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_client = _make_successful_client() + mock_cls.return_value = mock_client + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True) assert result["status"] is True assert "profile" in result - assert "name" in result["profile"] - assert "email" in result["profile"] - assert "prn" not in result["profile"] + assert result["profile"]["prn"] == "PES1201800001" + assert result["profile"]["branch"] == "Computer Science and Engineering" -def test_get_semester_from_class_branches(): - from app.pesu import _get_semester_from_class - assert _get_semester_from_class("4th Sem", None) == "Sem-4" - assert _get_semester_from_class("B.Tech CSE 2", None) == "Sem-2" - assert _get_semester_from_class(None, None) is None - assert _get_semester_from_class("NoSemesterHere", "") is None +@pytest.mark.asyncio +async def test_authenticate_success_with_profile_field_filtering(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True, fields=["name", "email"]) + assert "profile" in result + profile = result["profile"] + assert "name" in profile + assert "email" in profile + assert "prn" not in profile + assert "branch" not in profile -def test_parse_sslc_name_non_dict(pesu): - assert pesu._parse_sslc_name(None) is None - assert pesu._parse_sslc_name([]) is None - assert pesu._parse_sslc_name({"marks": "invalid"}) is None - assert pesu._parse_sslc_name({"marks": [None]}) is None +@pytest.mark.asyncio +async def test_authenticate_success_with_kycas(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) + assert result["status"] is True + assert "knowYourClassAndSection" in result + kycas = result["knowYourClassAndSection"] + assert kycas["prn"] == "PES1201800001" + assert kycas["instituteName"] == "PES University" @pytest.mark.asyncio -@patch("app.pesu.httpx.AsyncClient.post") -async def test_authenticate_invalid_json_responses(mock_post, pesu): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.side_effect = ValueError("Invalid JSON") - mock_post.return_value = mock_response +async def test_authenticate_success_with_profile_and_kycas(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate( + "user", "pass", profile=True, know_your_class_and_section=True + ) + assert "profile" in result + assert "knowYourClassAndSection" in result + + +@pytest.mark.asyncio +async def test_authenticate_success_with_kycas_field_filtering(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate( + "user", "pass", know_your_class_and_section=True, fields=["name", "semester"] + ) + kycas = result["knowYourClassAndSection"] + assert "name" in kycas + assert "semester" in kycas + assert "prn" not in kycas + assert "instituteName" not in kycas + - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Invalid JSON response" in str(exc_info.value) +@pytest.mark.asyncio +async def test_authenticate_no_profile_no_kycas_skips_name_fetch(pesu): + """When no profile or kycas requested, _fetch_name_as_in_sslc must NOT be called.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock()) as mock_fetch: + await pesu.authenticate("user", "pass") + mock_fetch.assert_not_called() + + +@pytest.mark.asyncio +async def test_authenticate_uses_name_sslc_when_available(pesu): + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value="Official Name")): + result = await pesu.authenticate("user", "pass", profile=True) + assert result["profile"]["name"] == "Official Name" + + +@pytest.mark.asyncio +async def test_authenticate_no_token_skips_sslc_fetch(pesu): + """If the response header has no token, _fetch_name_as_in_sslc is skipped.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_client = _make_successful_client(token="") + mock_cls.return_value = mock_client + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock()) as mock_fetch: + result = await pesu.authenticate("user", "pass", profile=True) + mock_fetch.assert_not_called() + assert result["profile"]["name"] == "Test User" # fallback from data + + +@pytest.mark.asyncio +async def test_authenticate_fields_defaults_to_default_fields(pesu): + """When fields=None, field_filtering should be False (all fields returned).""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True, fields=None) + profile = result["profile"] + # No field filtering → all profile keys present + for key in ["name", "prn", "srn", "branch", "semester", "section", "email", "phone"]: + assert key in profile, f"'{key}' unexpectedly missing" + + +@pytest.mark.asyncio +async def test_authenticate_custom_fields_triggers_field_filtering(pesu): + """When fields differ from DEFAULT_FIELDS, field_filtering=True applies.""" + with patch("app.pesu.httpx.AsyncClient") as mock_cls: + mock_cls.return_value = _make_successful_client() + with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): + result = await pesu.authenticate("user", "pass", profile=True, fields=["prn"]) + profile = result["profile"] + assert "prn" in profile + assert "name" not in profile From 377106aae76b4dec23bf2e13637281718ef21f0c Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:20:22 +0530 Subject: [PATCH 4/7] fix: reverted the changes made to test folder --- tests/unit/test_app_unit.py | 75 +- tests/unit/test_authenticate_unit.py | 186 ++-- tests/unit/test_pesu.py | 1324 +++++++++++++++----------- 3 files changed, 838 insertions(+), 747 deletions(-) diff --git a/tests/unit/test_app_unit.py b/tests/unit/test_app_unit.py index 84982c0..b5d0b93 100644 --- a/tests/unit/test_app_unit.py +++ b/tests/unit/test_app_unit.py @@ -1,27 +1,20 @@ -"""Unit tests for app/app.py — FastAPI application layer.""" - +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient -from app.app import app, main +from app.app import _csrf_token_refresh_loop, app, main @pytest.fixture def client(): - with TestClient(app, raise_server_exceptions=False) as c: - yield c - - -# --------------------------------------------------------------------------- -# /authenticate — validation & exception handling -# --------------------------------------------------------------------------- + with TestClient(app, raise_server_exceptions=False) as client: + yield client @patch("app.app.pesu_academy.authenticate") def test_authenticate_validation_error(mock_authenticate, client, caplog): - """If pesu_academy returns data that fails ResponseModel validation → 500.""" mock_authenticate.return_value = { "status": True, "message": "Login successful", @@ -38,7 +31,6 @@ def test_authenticate_validation_error(mock_authenticate, client, caplog): @patch("app.app.pesu_academy.authenticate") def test_authenticate_general_exception(mock_authenticate, client): - """Unhandled exception from pesu_academy → 500 with generic message.""" mock_authenticate.side_effect = Exception("Test exception") payload = {"username": "testuser", "password": "testpass", "profile": False} response = client.post("/authenticate", json=payload) @@ -47,57 +39,18 @@ def test_authenticate_general_exception(mock_authenticate, client): assert "Internal Server Error" in data["message"] -# --------------------------------------------------------------------------- -# /authenticate — successful mock response -# --------------------------------------------------------------------------- - - -@patch("app.app.pesu_academy.authenticate") -def test_authenticate_success_mocked(mock_authenticate, client): - """When pesu_academy returns a valid result the endpoint returns 200.""" - mock_authenticate.return_value = { - "status": True, - "message": "Login successful.", - } - payload = {"username": "testuser", "password": "testpass"} - response = client.post("/authenticate", json=payload) - assert response.status_code == 200 - data = response.json() - assert data["status"] is True - assert data["message"] == "Login successful." - assert "timestamp" in data - - -# --------------------------------------------------------------------------- -# /health -# --------------------------------------------------------------------------- - - -def test_health_endpoint(client): - """Health check returns 200 with status=True and message='ok'.""" - response = client.get("/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] is True - assert data["message"] == "ok" - assert "timestamp" in data - - -# --------------------------------------------------------------------------- -# /readme -# --------------------------------------------------------------------------- - - -def test_readme_redirect(client): - """README endpoint returns 308 redirect to GitHub.""" - response = client.get("/readme", follow_redirects=False) - assert response.status_code == 308 - assert "github.com" in response.headers["location"] +@pytest.mark.asyncio +@patch("asyncio.sleep", new_callable=AsyncMock) +@patch("app.app._refresh_csrf_token_with_lock") +async def test_csrf_token_refresh_loop_logs_exception_on_failure(mock_refresh, mock_sleep, caplog): + mock_refresh.side_effect = RuntimeError("Simulated CSRF refresh failure") + mock_sleep.side_effect = asyncio.CancelledError + with caplog.at_level("ERROR"): + with pytest.raises(asyncio.CancelledError): + await _csrf_token_refresh_loop() -# --------------------------------------------------------------------------- -# main() CLI function -# --------------------------------------------------------------------------- + assert "Failed to refresh unauthenticated CSRF token in the background." in caplog.text @patch("app.app.argparse.ArgumentParser.parse_args") diff --git a/tests/unit/test_authenticate_unit.py b/tests/unit/test_authenticate_unit.py index c306204..45d9a5a 100644 --- a/tests/unit/test_authenticate_unit.py +++ b/tests/unit/test_authenticate_unit.py @@ -1,10 +1,8 @@ -"""Unit tests for PESUAcademy.authenticate — current mobile-API implementation.""" - -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -from app.exceptions.authentication import AuthenticationError +from app.exceptions.authentication import AuthenticationError, CSRFTokenError from app.pesu import PESUAcademy @@ -13,153 +11,83 @@ def pesu(): return PESUAcademy() -def _make_client(login="SUCCESS", status_code=200, extra_data=None, token="tok", user_id="7"): - """Build a mock httpx.AsyncClient context manager for the authenticate() call.""" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - data = { - "login": login, - "userId": int(user_id) if user_id else None, - "loginId": "PES1201800001", - "departmentId": "PES1UG19CS001", - "className": "Sem-4", - "batchClass": None, - "program": "B.Tech.", - "branch": "CSE", - "sectionName": "B", - "email": "test@example.com", - "phone": "9876543210", - "name": "Test User", - } - if extra_data: - data.update(extra_data) - - mock_response = MagicMock() - mock_response.status_code = status_code - mock_response.json.return_value = data - mock_response.headers = {"mobileappauthenticationtoken": token} - mock_client.post.return_value = mock_response - return mock_client - - -# --------------------------------------------------------------------------- -# Success paths -# --------------------------------------------------------------------------- +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@pytest.mark.asyncio +async def test_authenticate_success_no_profile(mock_post, mock_get, pesu): + # Mock GET home page response with csrf token meta + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get_response.status_code = 200 + mock_get.return_value = mock_get_response + # Mock POST login success response with csrf token meta + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response -@pytest.mark.asyncio -async def test_authenticate_success_no_profile(pesu): - """Successful login with no profile requested returns only status and message.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client() - result = await pesu.authenticate("user", "pass", profile=False) + result = await pesu.authenticate("user", "pass", profile=False) assert result["status"] is True assert result["message"] == "Login successful." assert "profile" not in result +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") @pytest.mark.asyncio -async def test_authenticate_success_with_profile(pesu): - """Successful login with profile=True returns profile data.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True, fields=["prn", "name"]) +async def test_authenticate_success_with_profile(mock_get_profile, mock_post, mock_get, pesu): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_profile.return_value = { + "prn": "PES12345", + "name": "Test User", + "branch": "Computer Science and Engineering", + } + + result = await pesu.authenticate("user", "pass", profile=True, fields=["prn", "name"]) assert result["status"] is True assert "profile" in result assert "prn" in result["profile"] assert "name" in result["profile"] - # Field filtering works: branch was not requested + # Field filtering works, branch is omitted since not requested assert "branch" not in result["profile"] +@patch("app.pesu.httpx.AsyncClient.get") @pytest.mark.asyncio -async def test_authenticate_success_with_profile_all_fields(pesu): - """When fields=None all profile fields are returned without filtering.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True, fields=None) - - assert "profile" in result - for key in ["name", "prn", "srn", "branch", "semester", "section", "email", "phone"]: - assert key in result["profile"], f"'{key}' missing from profile" +async def test_authenticate_csrf_fetch_failure(mock_get, pesu): + mock_get.side_effect = CSRFTokenError("CSRF fetch failed") - -# --------------------------------------------------------------------------- -# Failure paths -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_authenticate_connection_failure(pesu): - """Network error during POST raises AuthenticationError.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post.side_effect = Exception("timeout") - mock_cls.return_value = mock_client - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Connection failed" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_authenticate_non_200_status(pesu): - """Non-200 HTTP status raises AuthenticationError.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client(status_code=503) - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "status code 503" in str(exc_info.value) + with pytest.raises(CSRFTokenError): + result = await pesu.authenticate("user", "pass") + assert result["status"] is False + assert "Unable to fetch csrf token" in result["message"] +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") @pytest.mark.asyncio -async def test_authenticate_login_failure_with_error_message(pesu): - """login != SUCCESS and errorMessage present → AuthenticationError with that message.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client( - login="FAILURE", - extra_data={"errorMessage": "Invalid username or password"}, - ) +async def test_authenticate_login_failure(mock_post, mock_get, pesu): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "wrongpass") - assert "Invalid username or password" in str(exc_info.value) + # Simulate login failure: login form div present + mock_post_response = AsyncMock() + mock_post_response.text = '
Login error
' + mock_post.return_value = mock_post_response + with pytest.raises(AuthenticationError): + result = await pesu.authenticate("user", "pass") -@pytest.mark.asyncio -async def test_authenticate_login_failure_no_error_message(pesu): - """login != SUCCESS without errorMessage → fallback error message used.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_client(login="FAILURE", extra_data={"errorMessage": None}) - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("baduser", "badpass") - assert "baduser" in str(exc_info.value) or "Invalid username" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_authenticate_invalid_json_raises_error(pesu): - """Unparseable JSON response raises AuthenticationError.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.side_effect = Exception("decode error") - mock_client.post.return_value = mock_response - mock_cls.return_value = mock_client - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Invalid JSON response" in str(exc_info.value) + assert result["status"] is False + assert "Invalid username or password" in result["message"] diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index 06377d1..cff8c6d 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -1,16 +1,15 @@ -"""Unit tests for app/pesu.py — PESUAcademy class (mobile-API implementation).""" - from unittest.mock import AsyncMock, MagicMock, patch import pytest -from app.exceptions.authentication import AuthenticationError -from app.pesu import BRANCH_MAPPING, PROGRAM_MAPPING, PESUAcademy, _get_semester_from_class - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- +from app.exceptions.authentication import ( + AuthenticationError, + CSRFTokenError, + KYCASFetchError, + ProfileFetchError, + ProfileParseError, +) +from app.pesu import PESUAcademy @pytest.fixture @@ -18,678 +17,889 @@ def pesu(): return PESUAcademy() -# --------------------------------------------------------------------------- -# _get_semester_from_class (module-level helper) -# --------------------------------------------------------------------------- - - -def test_get_semester_from_class_sem_prefix(): - assert _get_semester_from_class("Semester 3", None) == "Sem-3" - assert _get_semester_from_class("Sem-5", None) == "Sem-5" - assert _get_semester_from_class("Sem 2", None) == "Sem-2" - - -def test_get_semester_from_class_suffix(): - assert _get_semester_from_class("4th Sem", None) == "Sem-4" - assert _get_semester_from_class("6 Sem", None) == "Sem-6" - +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_http_error(mock_get, pesu): + mock_get.side_effect = Exception("HTTP request failed") + with pytest.raises(ProfileFetchError): + result = await pesu.get_profile_information(AsyncMock(), "testuser") + assert "error" in result + assert "Unable to fetch profile data" in result["error"] -def test_get_semester_from_class_bare_digit(): - assert _get_semester_from_class("7", None) == "Sem-7" +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_non_200_status(mock_get, pesu): + mock_response = AsyncMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + with pytest.raises(ProfileFetchError): + result = await pesu.get_profile_information(AsyncMock(), "testuser") + assert "error" in result + assert "Unable to fetch profile data" in result["error"] -def test_get_semester_from_class_falls_back_to_batch_class(): - assert _get_semester_from_class(None, "Sem-8") == "Sem-8" - assert _get_semester_from_class(None, "3") == "Sem-3" +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_authenticate_csrf_token_not_found(mock_get, pesu): + mock_response = AsyncMock() + mock_response.text = "No CSRF token here" + mock_get.return_value = mock_response + with pytest.raises(CSRFTokenError): + result = await pesu.authenticate("testuser", "testpass") + assert result["status"] is False + assert "Unable to fetch csrf token" in result["message"] + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@pytest.mark.asyncio +async def test_authenticate_post_request_failure(mock_post, mock_get, pesu): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + mock_post.side_effect = CSRFTokenError("POST request failed") + with pytest.raises(CSRFTokenError): + result = await pesu.authenticate("testuser", "testpass") + assert result["status"] is False + assert "Unable to authenticate" in result["message"] + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@pytest.mark.asyncio +async def test_authenticate_csrf_token_missing_after_login(mock_post, mock_get, pesu): + """Test authenticate when CSRF token is missing after successful login.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + mock_post_response = AsyncMock() + mock_post_response.text = "Login successful but no CSRF token" + mock_post.return_value = mock_post_response + with pytest.raises(CSRFTokenError): + result = await pesu.authenticate("testuser", "testpass") + assert result["status"] is True + assert result["message"] == "Login successful." + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") +@pytest.mark.asyncio +async def test_authenticate_with_profile_field_filtering( + mock_get_profile, + mock_post, + mock_get, + pesu, +): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + mock_get_profile.return_value = { + "name": "Test User", + "prn": "PES12345", + "email": "test@example.com", + "branch": "Computer Science", + "campus": "RR", + } + result = await pesu.authenticate("testuser", "testpass", profile=True, fields=["name", "email"]) + assert result["status"] is True + assert "profile" in result + assert "name" in result["profile"] + assert "email" in result["profile"] + assert "prn" not in result["profile"] + assert "branch" not in result["profile"] + assert "campus" not in result["profile"] -def test_get_semester_from_class_both_none(): - assert _get_semester_from_class(None, None) is None +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") +@pytest.mark.asyncio +async def test_authenticate_with_profile_no_field_filtering( + mock_get_profile, + mock_post, + mock_get, + pesu, +): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + mock_get_profile.return_value = dict.fromkeys(PESUAcademy.DEFAULT_FIELDS, "test_value") + result = await pesu.authenticate("testuser", "testpass", profile=True, fields=None) + assert result["status"] is True + for field in PESUAcademy.DEFAULT_FIELDS: + assert field in result["profile"] + assert result["profile"][field] == "test_value" -def test_get_semester_from_class_class_name_preferred(): - # class_name wins over batch_class - assert _get_semester_from_class("Sem-2", "Sem-8") == "Sem-2" +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_profile_parse_error(mock_get, mock_html_parser, pesu): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_get.return_value = mock_response + mock_soup = MagicMock() + mock_soup.any_css_matches.return_value = True + mock_soup.css.return_value = [MagicMock()] * 3 + mock_html_parser.return_value = mock_soup -# --------------------------------------------------------------------------- -# PROGRAM_MAPPING / BRANCH_MAPPING constants -# --------------------------------------------------------------------------- + client = AsyncMock() + client.get.return_value = mock_response + with pytest.raises(ProfileParseError): + await pesu.get_profile_information(client, "testuser") -def test_program_mapping_keys(): - assert "B.Tech." in PROGRAM_MAPPING - assert "M.Tech." in PROGRAM_MAPPING - assert PROGRAM_MAPPING["B.Tech."] == "Bachelor of Technology" - assert PROGRAM_MAPPING["MBA"] == "Master of Business Administration" +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_authenticate_login_form_present(mock_get, mock_post, mock_html_parser, pesu): + mock_get_response = MagicMock() + mock_get_response.text = '' + mock_get_response.status_code = 200 + mock_get.return_value = mock_get_response + mock_soup_csrf = MagicMock() + mock_soup_csrf.css_first.side_effect = lambda selector: ( + MagicMock(attributes={"content": "fake-csrf-token"}) if selector == "meta[name='csrf-token']" else None + ) + mock_soup_login = MagicMock() + mock_soup_login.css_first.side_effect = lambda selector: (MagicMock() if selector == "div.login-form" else None) + mock_html_parser.side_effect = [mock_soup_csrf, mock_soup_login] + mock_post_response = MagicMock() + mock_post_response.text = "
" + mock_post_response.status_code = 200 + mock_post.return_value = mock_post_response + with pytest.raises(AuthenticationError): + await pesu.authenticate("testuser", "testpass") + + +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_authenticate_csrf_token_missing_after_login_strict( + mock_get, + mock_post, + mock_html_parser, + pesu, +): + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + mock_post_response = AsyncMock() + mock_post_response.text = "Login successful but no CSRF token" + mock_post.return_value = mock_post_response + mock_soup = MagicMock() + + def css_first(selector): + if selector == "div.login-form": + return + if selector == "meta[name='csrf-token']": + return + return + + mock_soup.css_first.side_effect = css_first + mock_html_parser.return_value = mock_soup + with pytest.raises(CSRFTokenError): + await pesu.authenticate("testuser", "testpass") + + +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_unknown_campus_code( + mock_get, + mock_html_parser, + pesu, + caplog, +): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_get.return_value = mock_response + + def make_div(key, value): + div = MagicMock() + key_label = MagicMock() + key_label.text.return_value = key + value_label = MagicMock() + value_label.text.return_value = value + + def css_first(selector): + if selector == "label.lbl-title-light": + return key_label + if selector == "label.lbl-title-light + label": + return value_label + return None + + div.css_first.side_effect = css_first + return div + + form_group_elems = [ + make_div("Name", "Test User"), + make_div("SRN", "PES1234567"), + make_div("PESU Id", "PES3XXXXX"), + make_div("Program", "BTech"), + make_div("Branch", "Computer Science and Engineering"), + make_div("Semester", "6"), + make_div("Section", "A"), + ] -def test_branch_mapping_keys(): - assert "CSE" in BRANCH_MAPPING - assert "ECE" in BRANCH_MAPPING - assert BRANCH_MAPPING["CSE"] == "Computer Science and Engineering" + mock_soup = MagicMock() + mock_container = MagicMock() + mock_container.css.return_value = form_group_elems + email_node = MagicMock() + email_node.attributes = {"value": "test@example.com"} + phone_node = MagicMock() + phone_node.attributes = {"value": "1234567890"} -# --------------------------------------------------------------------------- -# PESUAcademy.DEFAULT_FIELDS -# --------------------------------------------------------------------------- + def css_first(selector): + if selector == "div.elem-info-wrapper": + return mock_container + if selector == "#updateMail": + return email_node + if selector == "#updateContact": + return phone_node + return None + mock_soup.css_first.side_effect = css_first + mock_html_parser.return_value = mock_soup -def test_default_fields_is_list(): - assert isinstance(PESUAcademy.DEFAULT_FIELDS, list) - expected = [ - "name", "prn", "srn", "program", "branch", "semester", - "section", "email", "phone", "campusCode", "campus", - "cycle", "department", "instituteName", + client = AsyncMock() + client.get.return_value = mock_response + + with caplog.at_level("INFO"): + profile = await pesu.get_profile_information(client, "testuser") + assert profile["prn"] == "PES3XXXXX" + assert profile["name"] == "Test User" + assert profile["branch"] == "Computer Science and Engineering" + assert profile["email"] == "test@example.com" + assert profile["phone"] == "1234567890" + assert any( + "Unknown campus code: 3 parsed from PRN=PES3XXXXX for user=testuser" in record.message + for record in caplog.records + ) + assert any( + "Complete profile information retrieved for user=testuser" in record.message for record in caplog.records + ) + + +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_campus_code_rr_ec(mock_get, mock_html_parser, pesu): + """Test that PRNs with PES1 and PES2 set the correct campus and campusCode.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_get.return_value = mock_response + + def make_div(key, value): + div = MagicMock() + key_label = MagicMock() + key_label.text.return_value = key + value_label = MagicMock() + value_label.text.return_value = value + + def css_first(selector): + if selector == "label.lbl-title-light": + return key_label + if selector == "label.lbl-title-light + label": + return value_label + return None + + div.css_first.side_effect = css_first + return div + + # Subcase 1: PES1... (RR campus) + form_group_elems_rr = [ + make_div("Name", "Test User"), + make_div("SRN", "PES1234567"), + make_div("PESU Id", "PES1XXXXX"), + make_div("Program", "BTech"), + make_div("Branch", "Computer Science and Engineering"), + make_div("Semester", "6"), + make_div("Section", "A"), ] - for field in expected: - assert field in PESUAcademy.DEFAULT_FIELDS, f"'{field}' missing from DEFAULT_FIELDS" + mock_soup_rr = MagicMock() + mock_container_rr = MagicMock() + mock_container_rr.css.return_value = form_group_elems_rr + mock_soup_rr.css_first.side_effect = ( + lambda selector: mock_container_rr if selector == "div.elem-info-wrapper" else None + ) + mock_html_parser.return_value = mock_soup_rr + client = AsyncMock() + client.get.return_value = mock_response + + profile_rr = await pesu.get_profile_information(client, "testuser") + assert profile_rr["campusCode"] == 1 + assert profile_rr["campus"] == "RR" + + # Subcase 2: PES2... (EC campus) + form_group_elems_ec = [ + make_div("Name", "Test User"), + make_div("SRN", "PES2234567"), + make_div("PESU Id", "PES2YYYYY"), + make_div("Program", "BTech"), + make_div("Branch", "Computer Science and Engineering"), + make_div("Semester", "6"), + make_div("Section", "A"), + ] + mock_soup_ec = MagicMock() + mock_container_ec = MagicMock() + mock_container_ec.css.return_value = form_group_elems_ec + mock_soup_ec.css_first.side_effect = ( + lambda selector: mock_container_ec if selector == "div.elem-info-wrapper" else None + ) + mock_html_parser.return_value = mock_soup_ec -def test_default_fields_includes_kycas_relevant_fields(): - """DEFAULT_FIELDS must include fields relevant to KYCAS filtering.""" - fields = PESUAcademy.DEFAULT_FIELDS - assert "semester" in fields - assert "cycle" in fields - assert "department" in fields - assert "instituteName" in fields + profile_ec = await pesu.get_profile_information(client, "testuser") + assert profile_ec["campusCode"] == 2 + assert profile_ec["campus"] == "EC" -# --------------------------------------------------------------------------- -# PESUAcademy._parse_sslc_name -# --------------------------------------------------------------------------- +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.get") +@pytest.mark.asyncio +async def test_get_profile_information_no_profile_data(mock_get, mock_html_parser, pesu): + """Test that ProfileParseError is raised when no profile data is parsed (parsing loop runs but nothing added).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_get.return_value = mock_response + mock_soup = MagicMock() + mock_soup.any_css_matches.return_value = True + mock_soup.css.return_value = [MagicMock(text=MagicMock(return_value="foo bar")) for _ in range(7)] + mock_soup.css_first.return_value = None + mock_html_parser.return_value = mock_soup + client = AsyncMock() + client.get.return_value = mock_response + with pytest.raises(ProfileParseError) as exc_info: + await pesu.get_profile_information(client, "testuser") + assert "Failed to parse student profile page from PESU Academy for user=testuser." in str(exc_info.value) + assert "The webpage might have changed." in str(exc_info.value) -def test_parse_sslc_name_returns_none_for_non_dict(pesu): - assert pesu._parse_sslc_name(None) is None - assert pesu._parse_sslc_name("string") is None - assert pesu._parse_sslc_name([]) is None -def test_parse_sslc_name_returns_none_when_list_has_no_dicts(pesu): - data = {"subject": ["not a dict", 42]} - assert pesu._parse_sslc_name(data) is None +@patch("app.pesu.HTMLParser") +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=MagicMock) +@pytest.mark.asyncio +async def test_get_profile_information_empty_profile_triggers_final_parse_error( + mock_extract, + mock_get, + mock_html_parser, + pesu, +): + mock_extract.return_value = None + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + mock_get.return_value = mock_response -def test_parse_sslc_name_returns_none_when_name_missing(pesu): - data = {"subject": [{"Score": 90}]} - assert pesu._parse_sslc_name(data) is None + mock_container = MagicMock() + mock_container.css.return_value = [MagicMock() for _ in range(7)] + mock_soup = MagicMock() + mock_soup.css_first.side_effect = lambda selector: mock_container if selector == "div.elem-info-wrapper" else None + mock_html_parser.return_value = mock_soup + client = AsyncMock() + client.get.return_value = mock_response -def test_parse_sslc_name_returns_none_when_name_empty(pesu): - data = {"subject": [{"NameAsInSSLC": " "}]} - assert pesu._parse_sslc_name(data) is None + with pytest.raises(ProfileParseError) as exc_info: + await pesu.get_profile_information(client, "testuser") + assert "No profile data could be extracted for user=testuser" in str(exc_info.value) -def test_parse_sslc_name_returns_stripped_name(pesu): - data = {"subject": [{"NameAsInSSLC": " John Doe "}]} - assert pesu._parse_sslc_name(data) == "John Doe" +@pytest.mark.asyncio +async def test_extract_and_update_profile_key_label_missing(pesu): + node = MagicMock() + node.css_first.return_value = None # key label missing + profile = {} + with pytest.raises(ProfileParseError) as exc_info: + await pesu._extract_and_update_profile(node, 0, profile) + assert "Could not parse key for field at index 0" in str(exc_info.value) -def test_parse_sslc_name_picks_first_non_empty(pesu): - data = { - "sub1": [{"NameAsInSSLC": " "}], - "sub2": [{"NameAsInSSLC": "Jane Doe"}], - } - result = pesu._parse_sslc_name(data) - assert result == "Jane Doe" +@pytest.mark.asyncio +async def test_extract_and_update_profile_value_label_missing(pesu): + node = MagicMock() + key_label = MagicMock() + key_label.text.return_value = "Name" + def css_first(selector): + if selector == "label.lbl-title-light": + return key_label + if selector == "label.lbl-title-light + label": + return None # value label missing + return None -# --------------------------------------------------------------------------- -# PESUAcademy._fetch_name_as_in_sslc -# --------------------------------------------------------------------------- + node.css_first.side_effect = css_first + profile = {} + with pytest.raises(ProfileParseError) as exc_info: + await pesu._extract_and_update_profile(node, 0, profile) + assert "Could not parse value for field at index 0" in str(exc_info.value) @pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_on_http_error(pesu): - client = AsyncMock() - client.post.side_effect = Exception("Connection error") - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None - +async def test_extract_and_update_profile_unknown_key(pesu): + node = MagicMock() + key_label = MagicMock() + key_label.text.return_value = "UnknownKey" + value_label = MagicMock() + value_label.text.return_value = "SomeValue" + + def css_first(selector): + if selector == "label.lbl-title-light": + return key_label + if selector == "label.lbl-title-light + label": + return value_label + return None + + node.css_first.side_effect = css_first + profile = {} + with pytest.raises(ProfileParseError) as exc_info: + await pesu._extract_and_update_profile(node, 0, profile) + assert "Unknown key: 'UnknownKey' in the profile page" in str(exc_info.value) -@pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_on_non_200_sem(pesu): - client = AsyncMock() - mock_resp = MagicMock() - mock_resp.status_code = 500 - client.post.return_value = mock_resp - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None +def test_default_fields_is_list(): + assert isinstance(PESUAcademy.DEFAULT_FIELDS, list) + assert "prn" in PESUAcademy.DEFAULT_FIELDS + assert "name" in PESUAcademy.DEFAULT_FIELDS + assert "srn" in PESUAcademy.DEFAULT_FIELDS + assert "program" in PESUAcademy.DEFAULT_FIELDS + assert "branch" in PESUAcademy.DEFAULT_FIELDS + assert "semester" in PESUAcademy.DEFAULT_FIELDS + assert "section" in PESUAcademy.DEFAULT_FIELDS + assert "email" in PESUAcademy.DEFAULT_FIELDS + assert "phone" in PESUAcademy.DEFAULT_FIELDS + assert "campusCode" in PESUAcademy.DEFAULT_FIELDS + assert "campus" in PESUAcademy.DEFAULT_FIELDS @pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_when_sem_data_empty(pesu): +async def test_get_kycas_http_exception(pesu): + """Test that the "Know Your Class and Section" fetch error is raised on request failure.""" client = AsyncMock() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = [] - client.post.return_value = mock_resp - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None - + client.post.side_effect = Exception("Connection error") -@pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_when_sem_data_not_list(pesu): - client = AsyncMock() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = {"not": "a list"} - client.post.return_value = mock_resp - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Failed to send "Know Your Class and Section" request' in str(exc_info.value) @pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_when_ids_missing(pesu): +async def test_get_kycas_non_200_status(pesu): + """Test that the "Know Your Class and Section" fetch error is raised on non-200 responses.""" client = AsyncMock() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = [{"BatchClassId": None, "ClassBatchSectionId": None}] - client.post.return_value = mock_resp - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None + mock_response = MagicMock() + mock_response.status_code = 500 + client.post.return_value = mock_response + + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert "Received status code 500" in str(exc_info.value) +@patch("app.pesu.HTMLParser") @pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_returns_none_on_non_200_results(pesu): +async def test_get_kycas_no_table(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when no table is found.""" client = AsyncMock() - sem_resp = MagicMock() - sem_resp.status_code = 200 - sem_resp.json.return_value = [{"BatchClassId": 1, "ClassBatchSectionId": 2}] + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response - results_resp = MagicMock() - results_resp.status_code = 403 - results_resp.json.return_value = {} + mock_soup = MagicMock() + mock_soup.css_first.return_value = None + mock_html_parser.return_value = mock_soup - client.post.side_effect = [sem_resp, results_resp] - result = await pesu._fetch_name_as_in_sslc(client, "token", "user123") - assert result is None + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" table' in str(exc_info.value) +@patch("app.pesu.HTMLParser") @pytest.mark.asyncio -async def test_fetch_name_as_in_sslc_happy_path(pesu): +async def test_get_kycas_no_headers(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when headers are empty.""" client = AsyncMock() - sem_resp = MagicMock() - sem_resp.status_code = 200 - sem_resp.json.return_value = [{"BatchClassId": 10, "ClassBatchSectionId": 20}] - - results_resp = MagicMock() - results_resp.status_code = 200 - results_resp.json.return_value = {"Math": [{"NameAsInSSLC": "Alice Smith", "Score": 95}]} - - client.post.side_effect = [sem_resp, results_resp] - result = await pesu._fetch_name_as_in_sslc(client, "tok", "u1") - assert result == "Alice Smith" - - -# --------------------------------------------------------------------------- -# PESUAcademy._map_data -# --------------------------------------------------------------------------- - - -def _sample_data(overrides=None): - base = { - "loginId": "PES1201800001", - "departmentId": "PES1UG19CS001", - "className": "Sem-4", - "batchClass": None, - "program": "B.Tech.", - "branch": "CSE", - "sectionName": "A", - "email": "test@example.com", - "phone": "9876543210", - "name": "Fallback Name", - } - if overrides: - base.update(overrides) - return base - - -def test_map_data_status_and_message(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=False, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - assert result["status"] is True - assert result["message"] == "Login successful." - - -def test_map_data_no_profile_no_kycas(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=False, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - assert "profile" not in result - assert "knowYourClassAndSection" not in result - - -def test_map_data_profile_included(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - assert "profile" in result - profile = result["profile"] - assert profile["prn"] == "PES1201800001" - assert profile["srn"] == "PES1UG19CS001" - assert profile["program"] == "Bachelor of Technology" - assert profile["branch"] == "Computer Science and Engineering" - assert profile["semester"] == "Sem-4" - assert profile["section"] == "A" - assert profile["email"] == "test@example.com" - assert profile["phone"] == "9876543210" - assert profile["campusCode"] == 1 - assert profile["campus"] == "RR" - - -def test_map_data_campus_code_ec(pesu): - data = _sample_data({"loginId": "PES2202100001"}) - result = pesu._map_data( - data, "user", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - assert result["profile"]["campusCode"] == 2 - assert result["profile"]["campus"] == "EC" - - -def test_map_data_campus_code_unknown(pesu): - """For a PRN with an unrecognised campus digit (e.g. PES3…), campus is None - but campusCode still receives the parsed integer from the regex.""" - data = _sample_data({"loginId": "PES3202100001"}) - result = pesu._map_data( - data, "user", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - # campusCode is set to the parsed digit (3) even when the campus name is unknown - assert result["profile"]["campusCode"] == 3 - assert result["profile"]["campus"] is None - - -def test_map_data_name_sslc_takes_priority(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False, - name_sslc="Official Name" - ) - assert result["profile"]["name"] == "Official Name" - - -def test_map_data_fallback_to_data_name(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False, - name_sslc=None - ) - assert result["profile"]["name"] == "Fallback Name" - - -def test_map_data_profile_field_filtering(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=True, - know_your_class_and_section=False, fields=["name", "email"], field_filtering=True - ) - profile = result["profile"] - assert "name" in profile - assert "email" in profile - assert "prn" not in profile - assert "branch" not in profile - - -def test_map_data_kycas_included(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=False, - know_your_class_and_section=True, fields=[], field_filtering=False - ) - assert "knowYourClassAndSection" in result - kycas = result["knowYourClassAndSection"] - assert kycas["prn"] == "PES1201800001" - assert kycas["srn"] == "PES1UG19CS001" - assert kycas["semester"] == "Sem-4" - assert kycas["section"] == "Section A" - assert kycas["cycle"] == "NA" - assert kycas["instituteName"] == "PES University" - assert kycas["branch"] == "CSE" - assert kycas["department"] == "CSE" - - -def test_map_data_kycas_section_none_when_no_section_name(pesu): - data = _sample_data({"sectionName": None}) - result = pesu._map_data( - data, "user", profile=False, - know_your_class_and_section=True, fields=[], field_filtering=False - ) - assert result["knowYourClassAndSection"]["section"] is None - - -def test_map_data_kycas_field_filtering(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=False, - know_your_class_and_section=True, fields=["name", "semester"], field_filtering=True - ) - kycas = result["knowYourClassAndSection"] - assert "name" in kycas - assert "semester" in kycas - assert "prn" not in kycas - assert "instituteName" not in kycas - - -def test_map_data_both_profile_and_kycas(pesu): - result = pesu._map_data( - _sample_data(), "user", profile=True, - know_your_class_and_section=True, fields=[], field_filtering=False - ) - assert "profile" in result - assert "knowYourClassAndSection" in result - + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
" + client.post.return_value = mock_response -def test_map_data_srn_falls_back_to_username_when_department_id_missing(pesu): - data = _sample_data({"departmentId": None}) - result = pesu._map_data( - data, "myusername", profile=True, - know_your_class_and_section=False, fields=[], field_filtering=False - ) - assert result["profile"]["srn"] == "myusername" + mock_table = MagicMock() + mock_table.css.return_value = [] + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup -# --------------------------------------------------------------------------- -# PESUAcademy.authenticate — error paths -# --------------------------------------------------------------------------- + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" table headers' in str(exc_info.value) +@patch("app.pesu.HTMLParser") @pytest.mark.asyncio -async def test_authenticate_connection_failure_raises_authentication_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.post.side_effect = Exception("Connection refused") - mock_client_cls.return_value = mock_client - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Connection failed" in str(exc_info.value) +async def test_get_kycas_no_data_row(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised when no row exists.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "
PRN
" + client.post.return_value = mock_response + mock_th = MagicMock() + mock_th.text.return_value = "PRN" -@pytest.mark.asyncio -async def test_authenticate_non_200_response_raises_authentication_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = None - mock_response = MagicMock() - mock_response.status_code = 403 - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "status code 403" in str(exc_info.value) + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Could not find "Know Your Class and Section" data row' in str(exc_info.value) +@patch("app.pesu.HTMLParser") @pytest.mark.asyncio -async def test_authenticate_invalid_json_response_raises_authentication_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) +async def test_get_kycas_header_cell_mismatch(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised on malformed rows.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.side_effect = Exception("Invalid JSON") - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client + mock_th1 = MagicMock() + mock_th1.text.return_value = "PRN" + mock_th2 = MagicMock() + mock_th2.text.return_value = "SRN" - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Invalid JSON response" in str(exc_info.value) + mock_td1 = MagicMock() + mock_td1.text.return_value = "PES1201800001" + mock_row = MagicMock() + mock_row.css.return_value = [mock_td1] -@pytest.mark.asyncio -async def test_authenticate_login_not_success_raises_authentication_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "login": "FAILURE", - "errorMessage": "Invalid username or password", - } - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "wrongpass") - assert "Invalid username or password" in str(exc_info.value) + mock_table = MagicMock() + mock_table.css.return_value = [mock_th1, mock_th2] + mock_table.css_first.return_value = mock_row + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup -@pytest.mark.asyncio -async def test_authenticate_login_not_dict_raises_authentication_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'Mismatch between "Know Your Class and Section" table headers' in str(exc_info.value) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = ["unexpected", "list"] - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client - with pytest.raises(AuthenticationError): - await pesu.authenticate("user", "pass") +@patch("app.pesu.HTMLParser") +@pytest.mark.asyncio +async def test_get_kycas_no_mapped_keys(mock_html_parser, pesu): + """Test that the "Know Your Class and Section" fetch error is raised on unknown headers.""" + client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + client.post.return_value = mock_response + mock_th = MagicMock() + mock_th.text.return_value = "UnknownHeader" -@pytest.mark.asyncio -async def test_authenticate_nested_json_string_parsed(pesu): - """If response.json() returns a string, it should be re-parsed as JSON.""" - import json as _json + mock_td = MagicMock() + mock_td.text.return_value = "some_value" - inner = {"login": "FAILURE", "errorMessage": "Nested error"} + mock_row = MagicMock() + mock_row.css.return_value = [mock_td] - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_table = MagicMock() + mock_table.css.return_value = [mock_th] + mock_table.css_first.return_value = mock_row - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = _json.dumps(inner) - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client + mock_soup = MagicMock() + mock_soup.css_first.return_value = mock_table + mock_html_parser.return_value = mock_soup - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Nested error" in str(exc_info.value) + with pytest.raises(KYCASFetchError) as exc_info: + await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + assert 'No "Know Your Class and Section" data could be extracted' in str(exc_info.value) @pytest.mark.asyncio -async def test_authenticate_invalid_nested_json_raises_error(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = "this is not valid { json" - mock_client.post.return_value = mock_response - mock_client_cls.return_value = mock_client - - with pytest.raises(AuthenticationError) as exc_info: - await pesu.authenticate("user", "pass") - assert "Invalid nested JSON" in str(exc_info.value) - - -# --------------------------------------------------------------------------- -# PESUAcademy.authenticate — success paths -# --------------------------------------------------------------------------- - - -def _make_successful_client(overrides=None, token="tok123", user_id="42"): - """Return a mock httpx.AsyncClient context manager for a successful login.""" - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - - data = { - "login": "SUCCESS", - "userId": int(user_id) if user_id else None, - "loginId": "PES1201800001", - "departmentId": "PES1UG19CS001", - "className": "Sem-4", - "batchClass": None, - "program": "B.Tech.", - "branch": "CSE", - "sectionName": "A", - "email": "test@example.com", - "phone": "9876543210", - "name": "Test User", - } - if overrides: - data.update(overrides) - +async def test_get_kycas_success(pesu): + """Test the happy path: successfully parsing "Know Your Class and Section" data.""" + client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = data - mock_response.headers = {"mobileappauthenticationtoken": token} - mock_client.post.return_value = mock_response - return mock_client + mock_response.text = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PRNSRNNameClassSectionCycleDepartmentBranchInstitute Name
PES2202100984PES2UG21CS310Test UserSem-8Section FNACSE(EC Campus)CSEPES University (Electronic City)
+ """ + client.post.return_value = mock_response + + result = await pesu.get_know_your_class_and_section(client, "fake-csrf", "testuser") + + assert result["prn"] == "PES2202100984" + assert result["srn"] == "PES2UG21CS310" + assert result["name"] == "Test User" + assert result["semester"] == "Sem-8" + assert result["section"] == "Section F" + assert result["cycle"] == "NA" + assert result["department"] == "CSE(EC Campus)" + assert result["branch"] == "CSE" + assert result["instituteName"] == "PES University (Electronic City)" + + +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") +@pytest.mark.asyncio +async def test_authenticate_passes_kycas_flag(mock_get_kycas, mock_post, mock_get, pesu): + """Test that authenticate calls get_know_your_class_and_section when the flag is set.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "instituteName": "PES University", + } + result = await pesu.authenticate("testuser", "testpass", profile=False, know_your_class_and_section=True) -@pytest.mark.asyncio -async def test_authenticate_success_no_profile_no_kycas(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - result = await pesu.authenticate("user", "pass") assert result["status"] is True - assert result["message"] == "Login successful." - assert "profile" not in result - assert "knowYourClassAndSection" not in result + assert result["knowYourClassAndSection"]["semester"] == "Sem-6" + mock_get_kycas.assert_called_once() +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") @pytest.mark.asyncio -async def test_authenticate_success_with_profile(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_client = _make_successful_client() - mock_cls.return_value = mock_client - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True) +async def test_authenticate_success_no_kycas(mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is NOT returned when not requested.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + result = await pesu.authenticate("user", "pass", know_your_class_and_section=False) assert result["status"] is True - assert "profile" in result - assert result["profile"]["prn"] == "PES1201800001" - assert result["profile"]["branch"] == "Computer Science and Engineering" + assert "knowYourClassAndSection" not in result +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") @pytest.mark.asyncio -async def test_authenticate_success_with_profile_field_filtering(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True, fields=["name", "email"]) - assert "profile" in result - profile = result["profile"] - assert "name" in profile - assert "email" in profile - assert "prn" not in profile - assert "branch" not in profile +async def test_authenticate_with_kycas(mock_get_kycas, mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is returned when requested.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "instituteName": "PES University", + } + result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) -@pytest.mark.asyncio -async def test_authenticate_success_with_kycas(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", know_your_class_and_section=True) assert result["status"] is True assert "knowYourClassAndSection" in result - kycas = result["knowYourClassAndSection"] - assert kycas["prn"] == "PES1201800001" - assert kycas["instituteName"] == "PES University" + assert result["knowYourClassAndSection"]["prn"] == "PES1201800001" + assert result["knowYourClassAndSection"]["instituteName"] == "PES University" +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") @pytest.mark.asyncio -async def test_authenticate_success_with_profile_and_kycas(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate( - "user", "pass", profile=True, know_your_class_and_section=True - ) - assert "profile" in result - assert "knowYourClassAndSection" in result +async def test_authenticate_with_kycas_field_filtering(mock_get_kycas, mock_post, mock_get, pesu): + """Test that "Know Your Class and Section" data is filtered when field filtering is enabled.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "srn": "PES1UG19CS001", + "name": "John Doe", + "semester": "Sem-6", + "section": "Section A", + "cycle": "NA", + "department": "CSE(RR Campus)", + "branch": "CSE", + "instituteName": "PES University", + } + result = await pesu.authenticate( + "user", + "pass", + know_your_class_and_section=True, + fields=["name", "semester"], + ) -@pytest.mark.asyncio -async def test_authenticate_success_with_kycas_field_filtering(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate( - "user", "pass", know_your_class_and_section=True, fields=["name", "semester"] - ) + assert result["status"] is True kycas = result["knowYourClassAndSection"] assert "name" in kycas assert "semester" in kycas assert "prn" not in kycas + assert "branch" not in kycas assert "instituteName" not in kycas +@patch("app.pesu.httpx.AsyncClient.get") +@patch("app.pesu.httpx.AsyncClient.post") +@patch("app.pesu.PESUAcademy.get_profile_information") +@patch("app.pesu.PESUAcademy.get_know_your_class_and_section") @pytest.mark.asyncio -async def test_authenticate_no_profile_no_kycas_skips_name_fetch(pesu): - """When no profile or kycas requested, _fetch_name_as_in_sslc must NOT be called.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock()) as mock_fetch: - await pesu.authenticate("user", "pass") - mock_fetch.assert_not_called() +async def test_authenticate_with_both_profile_and_kycas( + mock_get_kycas, mock_get_profile, mock_post, mock_get, pesu +): + """Test requesting both profile and "Know Your Class and Section" data simultaneously.""" + mock_get_response = AsyncMock() + mock_get_response.text = '' + mock_get.return_value = mock_get_response + + mock_post_response = AsyncMock() + mock_post_response.text = '' + mock_post.return_value = mock_post_response + + mock_get_profile.return_value = { + "name": "John Doe", + "prn": "PES1201800001", + "email": "john@example.com", + } + mock_get_kycas.return_value = { + "prn": "PES1201800001", + "semester": "Sem-6", + "section": "Section A", + } + result = await pesu.authenticate( + "user", "pass", profile=True, know_your_class_and_section=True + ) -@pytest.mark.asyncio -async def test_authenticate_uses_name_sslc_when_available(pesu): - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value="Official Name")): - result = await pesu.authenticate("user", "pass", profile=True) - assert result["profile"]["name"] == "Official Name" + assert result["status"] is True + assert "profile" in result + assert "knowYourClassAndSection" in result + assert result["profile"]["name"] == "John Doe" + assert result["knowYourClassAndSection"]["semester"] == "Sem-6" + +def test_kycas_header_to_key_map_is_dict(): + """Test that the "Know Your Class and Section" header map has expected keys.""" + kmap = PESUAcademy.KYCAS_HEADER_TO_KEY_MAP + assert isinstance(kmap, dict) + assert "PRN" in kmap + assert "SRN" in kmap + assert "Name" in kmap + assert "Class" in kmap + assert kmap["Class"] == "semester" + assert "Section" in kmap + assert "Cycle" in kmap + assert "Department" in kmap + assert "Branch" in kmap + assert "Institute Name" in kmap -@pytest.mark.asyncio -async def test_authenticate_no_token_skips_sslc_fetch(pesu): - """If the response header has no token, _fetch_name_as_in_sslc is skipped.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_client = _make_successful_client(token="") - mock_cls.return_value = mock_client - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock()) as mock_fetch: - result = await pesu.authenticate("user", "pass", profile=True) - mock_fetch.assert_not_called() - assert result["profile"]["name"] == "Test User" # fallback from data +def test_default_fields_includes_kycas_relevant_fields(): + """Test that DEFAULT_FIELDS includes fields relevant to "Know Your Class and Section" filtering.""" + fields = PESUAcademy.DEFAULT_FIELDS + assert "semester" in fields + assert "cycle" in fields + assert "department" in fields + assert "instituteName" in fields @pytest.mark.asyncio -async def test_authenticate_fields_defaults_to_default_fields(pesu): - """When fields=None, field_filtering should be False (all fields returned).""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True, fields=None) - profile = result["profile"] - # No field filtering → all profile keys present - for key in ["name", "prn", "srn", "branch", "semester", "section", "email", "phone"]: - assert key in profile, f"'{key}' unexpectedly missing" +@patch("app.pesu.PESUAcademy._fetch_new_client_with_csrf_token") +async def test_prefetch_client_closes_old_client_on_second_call(mock_fetch, pesu): + old_client = AsyncMock() + new_client = AsyncMock() + mock_fetch.side_effect = [ + (old_client, "token-1"), + (new_client, "token-2"), + ] + await pesu.prefetch_client_with_csrf_token() + await pesu.prefetch_client_with_csrf_token() -@pytest.mark.asyncio -async def test_authenticate_custom_fields_triggers_field_filtering(pesu): - """When fields differ from DEFAULT_FIELDS, field_filtering=True applies.""" - with patch("app.pesu.httpx.AsyncClient") as mock_cls: - mock_cls.return_value = _make_successful_client() - with patch.object(pesu, "_fetch_name_as_in_sslc", new=AsyncMock(return_value=None)): - result = await pesu.authenticate("user", "pass", profile=True, fields=["prn"]) - profile = result["profile"] - assert "prn" in profile - assert "name" not in profile + old_client.aclose.assert_awaited_once() From 2f66548f2bc2d51d7f87f7b3fce17a1286e5532e Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:44:14 +0530 Subject: [PATCH 5/7] refactor: improve documentation and add comments to PESUAcademy interface and FastAPI exception handlers --- app/app.py | 49 ++++++++++++++++++-------- app/pesu.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/app/app.py b/app/app.py index b76f2bf..6bc1bc4 100644 --- a/app/app.py +++ b/app/app.py @@ -33,11 +33,15 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - """Lifespan event handler for startup and shutdown events.""" - # Startup + """Lifespan event handler for FastAPI startup and shutdown events. + + This manages resources or sets up logs/connections at application start + and performs necessary cleanup upon shutdown. + """ + # Startup logging when application finishes initialization logging.info("PESUAuth API startup") yield - # Shutdown + # Shutdown logging when application process is terminating logging.info("PESUAuth API shutdown.") @@ -62,14 +66,19 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: }, ], ) +# Instantiate the PESUAcademy interface wrapper class pesu_academy = PESUAcademy() @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: - """Handler for request validation errors.""" + """Handler for FastAPI request validation errors (e.g. malformed JSON or missing fields). + + Translates internal validation errors into a clean, client-friendly 400 Bad Request JSON response. + """ logging.exception("Request data could not be validated.") errors = exc.errors() + # Format and join location/message strings for descriptive validation reports message = "; ".join([f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in errors]) return JSONResponse( status_code=400, @@ -83,7 +92,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.exception_handler(PESUAcademyError) async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSONResponse: - """Handler for PESUAcademy specific errors.""" + """Handler for PESUAcademy specific custom exceptions (e.g. authentication failures). + + Catches errors thrown within pesu.py and structures them into responses with corresponding status codes. + """ logging.exception(f"PESUAcademyError: {exc.message}") return JSONResponse( status_code=exc.status_code, @@ -97,7 +109,10 @@ async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSO @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: - """Handler for unhandled exceptions.""" + """Global handler for catching unexpected unhandled runtime exceptions. + + Prevents leaking raw stack traces to clients, returning a standard 500 Internal Server Error. + """ logging.exception("Unhandled exception occurred.") return JSONResponse( status_code=500, @@ -116,7 +131,10 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR tags=["Monitoring"], ) async def health() -> JSONResponse: - """Health check endpoint.""" + """Health check endpoint. + + Used by monitoring agents or load-balancers to verify that the service is running. + """ logging.debug("Health check requested.") return JSONResponse( status_code=200, @@ -136,7 +154,10 @@ async def health() -> JSONResponse: tags=["Documentation"], ) async def readme() -> RedirectResponse: - """Redirect to the PESUAuth GitHub repository.""" + """Redirect to the PESUAuth GitHub repository. + + Convenience endpoint that points to documentation repository on GitHub. + """ return RedirectResponse("https://github.com/pesu-dev/auth", status_code=308) @@ -158,14 +179,14 @@ async def authenticate(payload: RequestModel) -> JSONResponse: - fields (List[str], optional): Specific profile fields to include in the response. """ current_time = datetime.datetime.now(IST) - # Input has already been validated by the RequestModel + # Extract the payload variables validated by Pydantic's RequestModel username = payload.username password = payload.password profile = payload.profile know_your_class_and_section = payload.know_your_class_and_section fields = payload.fields - # Authenticate the user + # Begin authentication request call through the PESUAcademy API interface authentication_result = {"timestamp": current_time} logging.info(f"Authenticating user={username} with PESU Academy...") authentication_result.update( @@ -178,7 +199,7 @@ async def authenticate(payload: RequestModel) -> JSONResponse: ), ) - # Validate the response + # Validate response structure using the ResponseModel schema try: authentication_result = ResponseModel.model_validate(authentication_result) logging.info(f"Returning auth result for user={username}: {authentication_result}") @@ -197,7 +218,7 @@ async def authenticate(payload: RequestModel) -> JSONResponse: def main() -> None: - """Main function to run the FastAPI application with command line arguments.""" + """Main entrypoint function to parse CLI flags and run the FastAPI application.""" # Set up argument parser for command line arguments parser = argparse.ArgumentParser( description="PESUAuth API - A simple API to authenticate PESU credentials using PESU Academy.", @@ -221,7 +242,7 @@ def main() -> None: ) args = parser.parse_args() - # Set up logging configuration + # Configure logging levels and outputs based on debug flag settings logging_level = logging.DEBUG if args.debug else logging.INFO logging.basicConfig( level=logging_level, @@ -229,7 +250,7 @@ def main() -> None: filemode="w", ) - # Run the app + # Launch application using Uvicorn web server uvicorn.run("app.app:app", host=args.host, port=args.port, reload=args.debug) diff --git a/app/pesu.py b/app/pesu.py index 49d49a3..3044949 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -11,6 +11,7 @@ from app.exceptions.authentication import AuthenticationError +# used to to generate list of default field names ProfileField = Literal[ "name", "prn", @@ -28,6 +29,15 @@ "instituteName", ] +""" +Extract semester format (e.g. 'Sem-2') from class name or batch class. +Tries two possible source strings (class_name, then batch_class) and attempts +three regex patterns in order of specificity: + 1.Matches things like "Semester-3" or "Sem3" → extracts 3. + 2.Matches things like "3rd-Sem" → extracts 3. + 3.Falls back to just grabbing any standalone number in the string. +""" + def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> str | None: """Extract semester format (e.g. 'Sem-2') from class name or batch class.""" @@ -43,6 +53,9 @@ def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> return None +""" +PESU mobile api only returns B.Tech and and just CSE/ECE/EEE so to fix it we use this mapping. +""" PROGRAM_MAPPING = { "B.Tech.": "Bachelor of Technology", "B.Tech": "Bachelor of Technology", @@ -83,12 +96,18 @@ def __init__(self) -> None: pass def _parse_sslc_name(self, res_data: object) -> str | None: - """Parse nameAsInSSLC from the ISA marks response JSON.""" + """Parse nameAsInSSLC from the ISA marks response JSON. + + The response JSON has a structure where grades/marks are keyed by semester or test numbers. + This parses through the lists inside to extract the student's official name. + """ if not isinstance(res_data, dict): return None + # Iterate over all marks data in the JSON response dictionary for marks in res_data.values(): if not isinstance(marks, list): continue + # Look for the 'NameAsInSSLC' field inside individual subject marks dictionaries for mark in marks: if not isinstance(mark, dict): continue @@ -98,11 +117,18 @@ def _parse_sslc_name(self, res_data: object) -> str | None: return None async def _fetch_name_as_in_sslc(self, client: httpx.AsyncClient, token: str, user_id: str) -> str | None: - """Fetch the official name (nameAsInSSLC) from ISA results.""" + """Fetch the official name (nameAsInSSLC) from ISA results. + + The mobile API does not return the full official name in the primary login response. + Instead, we perform a multi-step query flow: + 1. Fetch the user's ISA semesters/classes. + 2. Retrieve the first/current semester's batch and section identifiers. + 3. Query the detailed ISA results for that semester which includes the student's name. + """ dispatcher_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" headers = {"mobileappauthenticationtoken": token} - # 1. Fetch ISA semesters + # Step 1. Fetch academic semesters listing under ISA results sem_payload = { "action": "6", "mode": "5", @@ -123,14 +149,14 @@ async def _fetch_name_as_in_sslc(self, client: httpx.AsyncClient, token: str, us if not isinstance(sem_data, list) or len(sem_data) == 0: return None - # Get the first/current semester + # Get the first/current semester info to retrieve active IDs semester = sem_data[0] batch_class_id = semester.get("BatchClassId") class_batch_section_id = semester.get("ClassBatchSectionId") if batch_class_id is None or class_batch_section_id is None: return None - # 2. Fetch ISA results for that semester + # Step 2. Fetch specific ISA results list for that semester/class ID results_payload = { "action": "6", "mode": "9", @@ -147,11 +173,52 @@ async def _fetch_name_as_in_sslc(self, client: httpx.AsyncClient, token: str, us if isinstance(res_data, str): res_data = json.loads(res_data) + # Step 3. Extract official name from the ISA results return self._parse_sslc_name(res_data) except Exception: + # Silently fallback if any network/parsing issue occurs during name resolution pass return None + """ + Example of actual raw JSON response returned by the PESU Academy mobile login API: + { + "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "userRoleId": "3", + "login": "SUCCESS", + "errorMessage": null, + "name": "John Doe", + "photo": "data:image/jpeg;base64,...", + "phone": "98X6X4X210", + "emergencyPhone": null, + "email": "johndoe@example.com", + "menuItems": null, + "cResults": null, + "results": null, + "serverMode": 0, + "redirectValue": "redirect:/a/ad", + "timeRemaining": null, + "status": 105, + "testStatus": 0, + "scheduledQuizTests": null, + "mobileAppTokenError": "SUCCESS", + "program": "B.Tech.", + "branch": "Branch:CSE", + "className": "Sem-2, Section A", + "batchClass": "3290", + "classBatchSection": "8648", + "sectionName": "Section A", + "programId": 1, + "classId": 2, + "loginId": "PES1201800001", + "departmentId": "PES1UG19CS001", + "usertype": "2", + "instId": 6, + "instIdNull": false, + "userParentList": [ ... ] + } + """ + def _map_data( self, data: dict[str, Any], @@ -162,11 +229,20 @@ def _map_data( field_filtering: bool, name_sslc: str | None = None, ) -> dict[str, Any]: - """Map the profile and class/section data from the response JSON.""" + """Map the profile and class/section data from the response JSON. + + Transforms the raw JSON structure returned by the PESU Mobile API + into the unified model schema used by the application, including: + - Mapping branch/program codes using PROGRAM_MAPPING and BRANCH_MAPPING. + - Resolving campus information based on the PRN campus code prefix. + - Filtering fields if custom fields selection is specified. + """ prn = data.get("loginId") srn = data.get("departmentId") or username campus_code = None campus = None + + # Deduce campus code and campus name prefix from PRN (e.g. PES1... or PES2...) if prn and (campus_code_match := re.match(r"PES(\d)", prn)): campus_code = int(campus_code_match.group(1)) if campus_code == 1: @@ -174,16 +250,17 @@ def _map_data( elif campus_code == 2: campus = "EC" + # Extract and normalize semester structure semester_val = _get_semester_from_class(data.get("className"), data.get("batchClass")) program_raw = data.get("program") program = PROGRAM_MAPPING.get(program_raw, program_raw) branch_raw = data.get("branch") branch = BRANCH_MAPPING.get(branch_raw, branch_raw) - # Prepare the authentication result dictionary + # Prepare base authentication response structure result = {"status": True, "message": "Login successful."} - # Fetch the profile information if profile details are requested + # Build and map profile information block if requested name = name_sslc or data.get("name") if profile: profile_dict = { @@ -199,12 +276,12 @@ def _map_data( "campusCode": campus_code, "campus": campus, } - # Filter the fields if field filtering is enabled + # Filter profile dictionary keys if field filtering is active if field_filtering: profile_dict = {k: v for k, v in profile_dict.items() if k in fields} result["profile"] = profile_dict - # Fetch the "Know Your Class and Section" data if requested + # Build and map Class and Section block if requested if know_your_class_and_section: kycas_dict = { "prn": prn, @@ -217,7 +294,7 @@ def _map_data( "branch": data.get("branch"), "instituteName": "PES University", } - # Filter the fields if field filtering is enabled + # Filter class and section dictionary keys if field filtering is active if field_filtering: kycas_dict = {k: v for k, v in kycas_dict.items() if k in fields} result["knowYourClassAndSection"] = kycas_dict From ecc9ff33e18d3bfb40c7ecaac9bc9449cdd8348a Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:18:40 +0530 Subject: [PATCH 6/7] refactor: replace complex ISA results parsing with direct profile retrieval from the new mobile API endpoint and expand mappings --- app/pesu.py | 297 ++++++++++++++++++++++--------------------- test_integration.py | 33 +++++ test_new_endpoint.py | 62 +++++++++ 3 files changed, 245 insertions(+), 147 deletions(-) create mode 100644 test_integration.py create mode 100644 test_new_endpoint.py diff --git a/app/pesu.py b/app/pesu.py index 3044949..2acbd96 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -1,4 +1,4 @@ -"""PESUAcademy class that serves as an interface to the PESU Academy website.""" +"""PESUAcademy class that serves as an interface to the PESU Academy Mobile API.""" from __future__ import annotations @@ -67,6 +67,14 @@ def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> "BBA": "Bachelor of Business Administration", "MBA.": "Master of Business Administration", "MBA": "Master of Business Administration", + "BCA": "Bachelor of Computer Applications", + "BCA.": "Bachelor of Computer Applications", + "B.Com": "Bachelor of Commerce", + "B.Com.": "Bachelor of Commerce", + "MCA": "Master of Computer Applications", + "MCA.": "Master of Computer Applications", + "B.DES": "Bachelor of Design", + "B.DES.": "Bachelor of Design", } BRANCH_MAPPING = { @@ -80,6 +88,16 @@ def _get_semester_from_class(class_name: str | None, batch_class: str | None) -> "ME": "Mechanical Engineering", "Branch:BT": "Biotechnology", "BT": "Biotechnology", + "Branch:CSE(AI-ML)": "Computer Science and Engineering (AI&ML)", + "CSE(AI-ML)": "Computer Science and Engineering (AI&ML)", + "Branch:CSE (AI&ML)": "Computer Science and Engineering (AI&ML)", + "CSE (AI&ML)": "Computer Science and Engineering (AI&ML)", + "Branch:AIML": "Computer Science and Engineering (AI&ML)", + "AIML": "Computer Science and Engineering (AI&ML)", + "Branch:CE": "Civil Engineering", + "CE": "Civil Engineering", + "Branch:CV": "Civil Engineering", + "CV": "Civil Engineering", } @@ -95,131 +113,87 @@ def __init__(self) -> None: """Initialize the PESUAcademy class.""" pass - def _parse_sslc_name(self, res_data: object) -> str | None: - """Parse nameAsInSSLC from the ISA marks response JSON. - - The response JSON has a structure where grades/marks are keyed by semester or test numbers. - This parses through the lists inside to extract the student's official name. - """ - if not isinstance(res_data, dict): - return None - # Iterate over all marks data in the JSON response dictionary - for marks in res_data.values(): - if not isinstance(marks, list): - continue - # Look for the 'NameAsInSSLC' field inside individual subject marks dictionaries - for mark in marks: - if not isinstance(mark, dict): - continue - name_sslc = mark.get("NameAsInSSLC") - if name_sslc and name_sslc.strip(): - return name_sslc.strip() - return None + """ + Example of raw JSON response returned by the PESU Academy dispatcher profile API (action=27, mode=1): + { + "MESSAGE": "SUCCESS_Record found Successfully", + "STUDENT_PHOTO": { + "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "loginId": "PES2UG25CS026", + "status": "A", + "gender": "Male", + "email": "student@example.com", + "mobile": "9876543210", + "firstName": "JOHN", + "nameAsInSSLC": "JOHN DOE", + "programId": 1, + "branchId": 32, + "instituteName": "PES University" + } + } + """ - async def _fetch_name_as_in_sslc(self, client: httpx.AsyncClient, token: str, user_id: str) -> str | None: - """Fetch the official name (nameAsInSSLC) from ISA results. + async def _fetch_profile_details( + self, client: httpx.AsyncClient, token: str, bearer_token: str, user_id: str + ) -> dict[str, Any] | None: + """Fetch the profile details from the dispatcher endpoint. - The mobile API does not return the full official name in the primary login response. - Instead, we perform a multi-step query flow: - 1. Fetch the user's ISA semesters/classes. - 2. Retrieve the first/current semester's batch and section identifiers. - 3. Query the detailed ISA results for that semester which includes the student's name. + This returns the actual name (nameAsInSSLC) and the correct PRN (loginId) + which are accurate compared to the general login response. """ dispatcher_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" - headers = {"mobileappauthenticationtoken": token} - - # Step 1. Fetch academic semesters listing under ISA results - sem_payload = { - "action": "6", - "mode": "5", - "userId": user_id, - "randomNum": "0.5", - "whichObjectId": "clickHome_footer_myresults", - "title": "ISA Results", - "serverMode": "0", - "redirectValue": "redirect:/a/ad", + headers = { + "mobileappauthenticationtoken": token, + "authorization": f"Bearer {bearer_token}", + } + files = { + "action": (None, "27"), + "mode": (None, "1"), + "userId": (None, user_id), + "searchUserId": (None, user_id), } - try: - sem_resp = await client.post(dispatcher_url, data=sem_payload, headers=headers) - if sem_resp.status_code != 200: - return None - sem_data = sem_resp.json() - if isinstance(sem_data, str): - sem_data = json.loads(sem_data) - if not isinstance(sem_data, list) or len(sem_data) == 0: - return None - - # Get the first/current semester info to retrieve active IDs - semester = sem_data[0] - batch_class_id = semester.get("BatchClassId") - class_batch_section_id = semester.get("ClassBatchSectionId") - if batch_class_id is None or class_batch_section_id is None: - return None - # Step 2. Fetch specific ISA results list for that semester/class ID - results_payload = { - "action": "6", - "mode": "9", - "userId": user_id, - "randomNum": "0.5", - "batchClassId": str(batch_class_id), - "classBatchSectionId": str(class_batch_section_id), - "fetchId": f"{batch_class_id}-{class_batch_section_id}", - } - res_resp = await client.post(dispatcher_url, data=results_payload, headers=headers) - if res_resp.status_code != 200: + try: + resp = await client.post(dispatcher_url, headers=headers, files=files) + if resp.status_code != 200: return None - res_data = res_resp.json() - if isinstance(res_data, str): - res_data = json.loads(res_data) + data = resp.json() + if isinstance(data, str): + data = json.loads(data) - # Step 3. Extract official name from the ISA results - return self._parse_sslc_name(res_data) + msg = data.get("MESSAGE") + if msg and "SUCCESS" in msg: + return data.get("STUDENT_PHOTO", {}) except Exception: - # Silently fallback if any network/parsing issue occurs during name resolution + # Silently fallback if any network/parsing issue occurs during profile resolution pass return None """ Example of actual raw JSON response returned by the PESU Academy mobile login API: { - "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "userRoleId": "3", - "login": "SUCCESS", - "errorMessage": null, - "name": "John Doe", - "photo": "data:image/jpeg;base64,...", - "phone": "98X6X4X210", - "emergencyPhone": null, - "email": "johndoe@example.com", - "menuItems": null, - "cResults": null, - "results": null, - "serverMode": 0, - "redirectValue": "redirect:/a/ad", - "timeRemaining": null, - "status": 105, - "testStatus": 0, - "scheduledQuizTests": null, - "mobileAppTokenError": "SUCCESS", - "program": "B.Tech.", - "branch": "Branch:CSE", - "className": "Sem-2, Section A", - "batchClass": "3290", - "classBatchSection": "8648", - "sectionName": "Section A", - "programId": 1, - "classId": 2, - "loginId": "PES1201800001", - "departmentId": "PES1UG19CS001", - "usertype": "2", - "instId": 6, - "instIdNull": false, - "userParentList": [ ... ] + "accessToken": "eyJhbGciOiJSUzI1NiJ9...", + "mobileJsonObject": { + "userId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "userRoleId": "3", + "login": "SUCCESS", + "errorMessage": null, + "name": "JOHN DOE", + "photo": "data:image/jpeg;base64,...", + "phone": "9876543210", + "email": "student@example.com", + "program": "B.Tech.", + "branch": "Branch:CSE", + "className": "Sem-2, Section A", + "sectionName": "Section A", + "loginId": "PES2202501872", + "departmentId": "0", + "usertype": "2" + } } """ - def _map_data( + def _map_data( # noqa: C901 self, data: dict[str, Any], username: str, @@ -227,18 +201,32 @@ def _map_data( know_your_class_and_section: bool, fields: list[str], field_filtering: bool, - name_sslc: str | None = None, + profile_details: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Map the profile and class/section data from the response JSON. - - Transforms the raw JSON structure returned by the PESU Mobile API - into the unified model schema used by the application, including: - - Mapping branch/program codes using PROGRAM_MAPPING and BRANCH_MAPPING. - - Resolving campus information based on the PRN campus code prefix. - - Filtering fields if custom fields selection is specified. + """Map the disparate data from both mobile endpoints into a single unified profile schema. + + The PESU Mobile API is fragmented. The initial login response returns a mix of + basic details, while the dispatcher endpoint provides the actual official records. + This method merges those two sources (represented by `data` and `profile_details`) + into the clean, structured format expected by the frontend clients. + + Key transformations include: + - Resolving the true PRN (Application Number) and SRN (University Roll Number), + which are returned ambiguously by the backend. + - Translating raw program/branch codes (e.g., "Branch:CSE(AI-ML)") into readable strings + ("Computer Science and Engineering (AI&ML)") using internal mappings. + - Deduce campus information (RR vs EC) dynamically based on the prefix of the PRN. + - Selectively returning only the fields requested by the client if `field_filtering` is enabled. """ + # --- PRN and SRN Resolution --- + # The new mobile API endpoints are notoriously confusing with identifiers: + # 1. The login endpoint (`data`) returns the Application Number (PRN) under the + # key `loginId` (e.g., PES2202501872). + # 2. The dispatcher endpoint (`profile_details`) returns the actual University SRN + # under the exact same `loginId` key (e.g., PES2UG25CS026). + # We must carefully map these to our explicit `prn` and `srn` keys to prevent downstream bugs. prn = data.get("loginId") - srn = data.get("departmentId") or username + srn = (profile_details.get("loginId") if profile_details else None) or data.get("departmentId") or username campus_code = None campus = None @@ -261,7 +249,7 @@ def _map_data( result = {"status": True, "message": "Login successful."} # Build and map profile information block if requested - name = name_sslc or data.get("name") + name = (profile_details.get("nameAsInSSLC") if profile_details else None) or data.get("name") if profile: profile_dict = { "name": name, @@ -271,8 +259,8 @@ def _map_data( "branch": branch, "semester": semester_val, "section": data.get("sectionName"), - "email": data.get("email"), - "phone": data.get("phone"), + "email": (profile_details.get("email") if profile_details else None) or data.get("email"), + "phone": (profile_details.get("mobile") if profile_details else None) or data.get("phone"), "campusCode": campus_code, "campus": campus, } @@ -283,16 +271,33 @@ def _map_data( # Build and map Class and Section block if requested if know_your_class_and_section: + # Deduce missing information originally provided by the web scraping API + kycas_branch = data.get("branch", "").replace("Branch:", "") + kycas_inst = "PES University (Electronic City)" if campus == "EC" else "PES University (Ring Road Campus)" + + kycas_cycle = "NA" + kycas_dept = branch + if semester_val in ("Sem-1", "Sem-2"): + kycas_dept = f"S & H - PESU ({campus} Campus)" + section_letter = str(data.get("sectionName", "")).replace("Section ", "").strip() + + if section_letter: + is_a_to_n = section_letter[0].upper() <= "N" + if semester_val == "Sem-1": + kycas_cycle = "Chemistry Cycle" if is_a_to_n else "Physics Cycle" + else: # Sem-2 + kycas_cycle = "Physics Cycle" if is_a_to_n else "Chemistry Cycle" + kycas_dict = { "prn": prn, "srn": srn, "name": name, "semester": semester_val, - "section": f"Section {data.get('sectionName')}" if data.get("sectionName") else None, - "cycle": "NA", - "department": data.get("branch"), - "branch": data.get("branch"), - "instituteName": "PES University", + "section": data.get("sectionName"), + "cycle": kycas_cycle, + "department": kycas_dept, + "branch": kycas_branch, + "instituteName": kycas_inst, } # Filter class and section dictionary keys if field filtering is active if field_filtering: @@ -334,24 +339,18 @@ async def authenticate( ) # Prepare the payload for mobile login API - login_url = "https://www.pesuacademy.com/MAcademy/j_spring_security_check" - payload = { - "j_username": username, - "j_password": password, - "j_mobile": "MOBILE", - "j_mobileApp": "YES", - "j_social": "NO", - "j_appId": "1", - "action": "0", - "mode": "0", - "whichObjectId": "loginSubmitButton", - "randomNum": 0.5, + login_url = "https://www.pesuacademy.com/MAcademy/mobile/mobilelogin/auth" + files = { + "userName": (None, username), + "password": (None, password), + "j_appId": (None, "YES"), + "instId": (None, "1,6,7,14"), } # Make a post request to authenticate the user async with httpx.AsyncClient(follow_redirects=True, timeout=15.0) as client: try: - response = await client.post(login_url, data=payload) + response = await client.post(login_url, files=files) except Exception as e: raise AuthenticationError(f"Connection failed: {str(e)}") @@ -373,9 +372,11 @@ async def authenticate( except Exception: raise AuthenticationError("Authentication failed: Invalid nested JSON response") - # If the response does not indicate success, raise an AuthenticationError - if not isinstance(data, dict) or data.get("login") != "SUCCESS": - error_msg = data.get("errorMessage") if isinstance(data, dict) else None + # Validate the mobileJsonObject structure + mobile_obj = data.get("mobileJsonObject") if isinstance(data, dict) else None + + if not isinstance(mobile_obj, dict) or mobile_obj.get("login") != "SUCCESS": + error_msg = mobile_obj.get("errorMessage") if isinstance(mobile_obj, dict) else None raise AuthenticationError( error_msg or f"Invalid username or password, or user does not exist for user={username}." ) @@ -388,18 +389,20 @@ async def authenticate( return {"status": True, "message": "Login successful."} token = response.headers.get("mobileappauthenticationtoken") or "" - user_id = str(data.get("userId") or "") - name_sslc = None - if token and user_id: - name_sslc = await self._fetch_name_as_in_sslc(client, token, user_id) + bearer_token = data.get("accessToken") or mobile_obj.get("accessToken") or "" + user_id = str(mobile_obj.get("userId") or "") + + profile_details = None + if token and user_id and bearer_token: + profile_details = await self._fetch_profile_details(client, token, bearer_token, user_id) # Map the parsed response JSON to return format return self._map_data( - data=data, + data=mobile_obj, username=username, profile=profile, know_your_class_and_section=know_your_class_and_section, fields=fields, field_filtering=field_filtering, - name_sslc=name_sslc, + profile_details=profile_details, ) diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..601dc80 --- /dev/null +++ b/test_integration.py @@ -0,0 +1,33 @@ +"""Test integration script.""" + +import asyncio +import json +import os + +from dotenv import load_dotenv + +from app.pesu import PESUAcademy + +load_dotenv() + + +async def test_integration() -> None: + """Run the integration test for PESUAcademy authentication.""" + pesu = PESUAcademy() + username = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + + if not username or not password: + print("Missing credentials in .env") + return + + print(f"Testing authenticate for: {username}") + result = await pesu.authenticate( + username=username, password=password, profile=True, know_your_class_and_section=True + ) + + print("--- Authentication Result ---") + print(json.dumps(result, indent=2)) + + +asyncio.run(test_integration()) diff --git a/test_new_endpoint.py b/test_new_endpoint.py new file mode 100644 index 0000000..e918f39 --- /dev/null +++ b/test_new_endpoint.py @@ -0,0 +1,62 @@ +"""Test new endpoint script.""" + +import asyncio +import os + +import httpx +from dotenv import load_dotenv + +load_dotenv() + + +async def test_httpx() -> None: + """Run the http test against PESUAcademy endpoints.""" + url = "https://www.pesuacademy.com/MAcademy/mobile/mobilelogin/auth" + + username = os.getenv("TEST_EMAIL") + password = os.getenv("TEST_PASSWORD") + + if not username or not password: + print("Error: TEST_EMAIL or TEST_PASSWORD not found in .env file") + return + + files = { + "userName": (None, username), + "password": (None, password), + "j_appId": (None, "YES"), + "instId": (None, "1,6,7,14"), + } + async with httpx.AsyncClient() as client: + resp = await client.post(url, files=files) + print("Login status code:", resp.status_code) + + token = resp.headers.get("mobileappauthenticationtoken") + + try: + data = resp.json() + mobile_obj = data.get("mobileJsonObject", {}) + user_id = mobile_obj.get("userId") + bearer_token = data.get("accessToken") or mobile_obj.get("accessToken") + print("Login success:", mobile_obj.get("login")) + print("Token:", token is not None) + disp_url = "https://www.pesuacademy.com/MAcademy/mobile/dispatcher" + disp_headers = {"authorization": f"Bearer {bearer_token}", "mobileappauthenticationtoken": token} + disp_files = { + "action": (None, "27"), + "mode": (None, "1"), + "userId": (None, user_id), + "searchUserId": (None, user_id), + } + disp_resp = await client.post(disp_url, headers=disp_headers, files=disp_files) + print("Dispatcher status code:", disp_resp.status_code) + disp_data = disp_resp.json() + print("Dispatcher message:", disp_data.get("MESSAGE")) + student = disp_data.get("STUDENT_PHOTO", {}) + print("Name:", student.get("nameAsInSSLC")) + + except Exception as e: + print("Error parsing json:", e) + print(resp.text) + + +asyncio.run(test_httpx()) From ccdebbbf2db93ebdb44336217fa2bd3471bc798a Mon Sep 17 00:00:00 2001 From: jois-code <67776857+achyuthjoism@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:16:07 +0530 Subject: [PATCH 7/7] refactor: simplify code documentation, update default port configuration, and migrate integration test file --- .gitignore | 1 - app/app.py | 27 +++++++-------------------- app/pesu.py | 2 +- test_integration.py | 33 --------------------------------- 4 files changed, 8 insertions(+), 55 deletions(-) delete mode 100644 test_integration.py diff --git a/.gitignore b/.gitignore index cf73cf4..e40bb4b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ bruno.json .uv/ .ruff_cache/ *.csv -.DS_Store *.png diff --git a/app/app.py b/app/app.py index 6bc1bc4..66d0870 100644 --- a/app/app.py +++ b/app/app.py @@ -5,7 +5,6 @@ import argparse import datetime import logging -import os from contextlib import asynccontextmanager from importlib.metadata import version from typing import TYPE_CHECKING @@ -92,10 +91,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.exception_handler(PESUAcademyError) async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSONResponse: - """Handler for PESUAcademy specific custom exceptions (e.g. authentication failures). - - Catches errors thrown within pesu.py and structures them into responses with corresponding status codes. - """ + """Handler for PESUAcademy specific errors.""" logging.exception(f"PESUAcademyError: {exc.message}") return JSONResponse( status_code=exc.status_code, @@ -109,10 +105,7 @@ async def pesu_exception_handler(request: Request, exc: PESUAcademyError) -> JSO @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: - """Global handler for catching unexpected unhandled runtime exceptions. - - Prevents leaking raw stack traces to clients, returning a standard 500 Internal Server Error. - """ + """Handler for unhandled exceptions.""" logging.exception("Unhandled exception occurred.") return JSONResponse( status_code=500, @@ -131,10 +124,7 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR tags=["Monitoring"], ) async def health() -> JSONResponse: - """Health check endpoint. - - Used by monitoring agents or load-balancers to verify that the service is running. - """ + """Health check endpoint.""" logging.debug("Health check requested.") return JSONResponse( status_code=200, @@ -154,10 +144,7 @@ async def health() -> JSONResponse: tags=["Documentation"], ) async def readme() -> RedirectResponse: - """Redirect to the PESUAuth GitHub repository. - - Convenience endpoint that points to documentation repository on GitHub. - """ + """Redirect to the PESUAuth GitHub repository.""" return RedirectResponse("https://github.com/pesu-dev/auth", status_code=308) @@ -218,7 +205,7 @@ async def authenticate(payload: RequestModel) -> JSONResponse: def main() -> None: - """Main entrypoint function to parse CLI flags and run the FastAPI application.""" + """Main function to run the FastAPI application with command line arguments.""" # Set up argument parser for command line arguments parser = argparse.ArgumentParser( description="PESUAuth API - A simple API to authenticate PESU credentials using PESU Academy.", @@ -232,7 +219,7 @@ def main() -> None: parser.add_argument( "--port", type=int, - default=int(os.environ.get("PORT", 5000)), + default=5000, help="Port to run the FastAPI application on. Default is 5000", ) parser.add_argument( @@ -242,7 +229,7 @@ def main() -> None: ) args = parser.parse_args() - # Configure logging levels and outputs based on debug flag settings + # Setup logging configuration logging_level = logging.DEBUG if args.debug else logging.INFO logging.basicConfig( level=logging_level, diff --git a/app/pesu.py b/app/pesu.py index 2acbd96..a54033d 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -114,7 +114,7 @@ def __init__(self) -> None: pass """ - Example of raw JSON response returned by the PESU Academy dispatcher profile API (action=27, mode=1): + Raw JSON response format { "MESSAGE": "SUCCESS_Record found Successfully", "STUDENT_PHOTO": { diff --git a/test_integration.py b/test_integration.py deleted file mode 100644 index 601dc80..0000000 --- a/test_integration.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test integration script.""" - -import asyncio -import json -import os - -from dotenv import load_dotenv - -from app.pesu import PESUAcademy - -load_dotenv() - - -async def test_integration() -> None: - """Run the integration test for PESUAcademy authentication.""" - pesu = PESUAcademy() - username = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - - if not username or not password: - print("Missing credentials in .env") - return - - print(f"Testing authenticate for: {username}") - result = await pesu.authenticate( - username=username, password=password, profile=True, know_your_class_and_section=True - ) - - print("--- Authentication Result ---") - print(json.dumps(result, indent=2)) - - -asyncio.run(test_integration())