From d418959892f8f87b2501f6a23c20ffa4e643308b Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Tue, 11 Aug 2026 11:49:37 +0530 Subject: [PATCH 1/2] chore: deprecate KYCAS feature --- README.md | 29 -- app/app.py | 2 - app/docs/authenticate.py | 50 --- app/exceptions/authentication.py | 11 - app/models/__init__.py | 1 - app/models/kycas.py | 65 --- app/models/request.py | 10 - app/models/response.py | 11 +- app/pesu.py | 121 ------ scripts/benchmark/benchmark_requests.py | 9 - scripts/benchmark/util.py | 3 - .../test_authenticate_functional.py | 86 ---- tests/integration/test_app_integration.py | 116 ----- tests/unit/test_pesu.py | 411 +----------------- tests/unit/test_request_model.py | 73 +--- 15 files changed, 14 insertions(+), 984 deletions(-) delete mode 100644 app/models/kycas.py diff --git a/README.md b/README.md index d27fe22..b88fd0c 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ object, with the user's profile information if requested. | `username` | No | `str` | | The user's SRN or PRN | | `password` | No | `str` | | The user's password | | `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | -| `knowYourClassAndSection` | Yes | `boolean` | `False` | Whether to fetch data from PESU's "Know Your Class and Section" information | | `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -129,7 +128,6 @@ profile data was requested, the response's `profile` key will store a dictionary | ------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | | `status` | `boolean` | A flag indicating whether the overall request was successful | | `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | -| `knowYourClassAndSection` | `KnowYourClassAndSectionObject` | A nested map storing the profile information from PESU's "Know Your Class and Section" endpoint | | `message` | `str` | A message that provides information corresponding to the status | | `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | @@ -152,21 +150,6 @@ If the authentication fails, this field will not be present in the response. | `campusCode` | The integer code of the campus (1 for RR and 2 for EC) | | `campus` | Abbreviation of the user's campus name | -#### `KnowYourClassAndSectionObject` - -| **Field** | **Description** | -| --------------- | ------------------------------------------------------------------------ | -| `prn` | PRN of the user | -| `srn` | SRN of the user | -| `name` | Name of the user | -| `semester` | Current semester that the user is in | -| `section` | Section of the user | -| `cycle` | Physics Cycle or Chemistry Cycle, if the user is in first year | -| `department` | Abbreviation of the branch along with the campus the user is studying in | -| `branch` | Abbreviation of the branch that the user is pursuing | -| `instituteName` | The name of the campus that the user is studying in | -| `error` | The error name and stack trace, if an error occurs | - ### `/health` This endpoint can be used to check the health of the API. It's useful for monitoring and uptime checks. This endpoint @@ -199,7 +182,6 @@ data = { "username": "your SRN or PRN here", "password": "your password here", "profile": True, # Optional, defaults to False - 'knowYourClassAndSection': True, # Optional, defaults to False } response = requests.post("http://localhost:5000/authenticate", json=data) @@ -225,17 +207,6 @@ print(response.json()) "campus": "RR" }, "message": "Login successful.", - "knowYourClassAndSection": { - "prn": "PES1201800001", - "srn": "PES1201800001", - "name": "JOHNNY BLAZE", - "semester": "Sem-8", - "section": "Section F", - "cycle": "NA", - "department": "CSE(EC Campus)", - "branch": "CSE", - "instituteName": "PES University (Electronic City)" - }, "timestamp": "2024-07-28 22:30:10.103368+05:30" } ``` diff --git a/app/app.py b/app/app.py index 7998c9a..7fc99d08 100644 --- a/app/app.py +++ b/app/app.py @@ -202,7 +202,6 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) 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 @@ -213,7 +212,6 @@ async def authenticate(payload: RequestModel, background_tasks: BackgroundTasks) username=username, password=password, profile=profile, - know_your_class_and_section=know_your_class_and_section, fields=fields, ), ) diff --git a/app/docs/authenticate.py b/app/docs/authenticate.py index a8acd47..39ef84f 100644 --- a/app/docs/authenticate.py +++ b/app/docs/authenticate.py @@ -23,16 +23,6 @@ "profile": True, }, }, - "auth_with_kycas": { - "summary": 'Authentication with "Know Your Class and Section" endpoint', - "description": 'Authentication with "Know Your Class and Section" data', - "value": { - "username": "PES1201800001", - "password": "mySecurePassword123", - "profile": True, - "knowYourClassAndSection": True, - }, - }, "phone_auth_selective_fields": { "summary": "Authentication with Selected Fields", "description": "Authentication using username and requesting specific profile data fields", @@ -84,38 +74,6 @@ }, }, }, - "authentication_with_kycas": { - "summary": 'Authentication with "Know Your Class and Section" data"', - "value": { - "status": True, - "message": "Login successful.", - "timestamp": "2024-07-28T22:30:10.103368+05:30", - "profile": { - "name": "John Doe", - "prn": "PESXXYYZZZZZ", - "srn": "PESXXUGYYZZZ", - "program": "Bachelor of Technology", - "branch": "Computer Science and Engineering", - "semester": "2", - "section": "C", - "email": "johndoe@gmail.com", - "phone": "1234567890", - "campusCode": 1, - "campus": "RR", - }, - "knowYourClassAndSection": { - "prn": "PESXXYYZZZZZ", - "srn": "PESXXUGYYZZZ", - "name": "John Doe", - "semester": "Sem-X", - "section": "Section X", - "cycle": "NA", - "department": "Computer Science and Engineering", - "branch": "CSE", - "instituteName": "PES University", - }, - }, - }, "authentication_with_selected_fields": { "summary": "Authentication with Selected Fields", "value": { @@ -209,14 +167,6 @@ "timestamp": "2024-07-28T22:30:10.103368+05:30", }, }, - "kycas_fetch_error": { - "summary": '"Know Your Class and Section" endpoint fetching failed', - "value": { - "status": False, - "message": "Failed to fetch Know Your Class and Section data from PESU Academy.", - "timestamp": "2024-07-28T22:30:10.103368+05:30", - }, - }, } } }, diff --git a/app/exceptions/authentication.py b/app/exceptions/authentication.py index 62560d8..03833c8 100644 --- a/app/exceptions/authentication.py +++ b/app/exceptions/authentication.py @@ -33,14 +33,3 @@ class ProfileParseError(PESUAcademyError): 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/models/__init__.py b/app/models/__init__.py index 3791feb..c06043f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,5 @@ """Custom models for the PESUAuth API.""" -from .kycas import KYCASModel as KYCASModel from .profile import ProfileModel as ProfileModel from .request import RequestModel as RequestModel from .response import ResponseModel as ResponseModel diff --git a/app/models/kycas.py b/app/models/kycas.py deleted file mode 100644 index 4934a36..0000000 --- a/app/models/kycas.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Model representing the "Know Your Class and Section" data returned after successful authentication.""" - -from pydantic import BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel - - -class KYCASModel(BaseModel): - """Model representing the "Know Your Class and Section" data.""" - - model_config = ConfigDict(strict=True, alias_generator=to_camel, populate_by_name=True) - - prn: str | None = Field( - None, - title="PRN", - description="PRN of the user.", - json_schema_extra={"example": "PESXXYYZZZZZ"}, - ) - srn: str | None = Field( - None, - title="SRN", - description="SRN of the user.", - json_schema_extra={"example": "PESXXUGYYZZZ"}, - ) - name: str | None = Field( - None, - title="Name", - description="Full name of the user.", - json_schema_extra={"example": "John Doe"}, - ) - semester: str | None = Field( - None, - title="Semester", - description="Semester the user belongs to.", - json_schema_extra={"example": "Sem-X"}, - ) - section: str | None = Field( - None, - title="Section", - description="Section the user belongs to.", - json_schema_extra={"example": "Section X"}, - ) - cycle: str | None = Field( - None, - title="Cycle", - description="Cycle the user belongs to.", - json_schema_extra={"example": "NA"}, - ) - department: str | None = Field( - None, - title="Department", - description="Department the user belongs to.", - json_schema_extra={"example": "Computer Science and Engineering"}, - ) - branch: str | None = Field( - None, - title="Branch", - description="Abbreviation of the branch that the user is pursuing.", - json_schema_extra={"example": "CSE"}, - ) - institute_name: str | None = Field( - None, - title="Institute Name", - description="Institute the user belongs to.", - json_schema_extra={"example": "PES University"}, - ) diff --git a/app/models/request.py b/app/models/request.py index c770819..7b03038 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -32,16 +32,6 @@ class RequestModel(BaseModel): json_schema_extra={"example": True}, ) - know_your_class_and_section: bool = Field( - False, - title="Know Your Class and Section Flag", - description=( - "Whether to fetch the user's class and section information from the " - '"Know Your Class and Section" endpoint.' - ), - json_schema_extra={"example": True}, - ) - fields: list[ProfileField] | None = Field( None, title="Profile Fields", diff --git a/app/models/response.py b/app/models/response.py index f368749..8e8b108 100644 --- a/app/models/response.py +++ b/app/models/response.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from app.models import KYCASModel, ProfileModel +from app.models import ProfileModel class ResponseModel(BaseModel): @@ -39,12 +39,3 @@ class ResponseModel(BaseModel): title="User Profile Data", description="The user's profile data returned only if authentication succeeds and profile data was requested.", ) - - know_your_class_and_section: KYCASModel | None = Field( - None, - title='"Know Your Class and Section" Data', - description=( - "The user's class and section data from the " - '"Know Your Class and Section" endpoint returned only if authentication succeeds.' - ), - ) diff --git a/app/pesu.py b/app/pesu.py index bc70371..5ed0a76 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -12,7 +12,6 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, - KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -29,9 +28,6 @@ "phone", "campusCode", "campus", - "cycle", - "department", - "instituteName", ] @@ -64,18 +60,6 @@ class PESUAcademy: "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 @@ -272,93 +256,11 @@ async def get_profile_information( 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 - - if not kycas: - raise KYCASFetchError( - f'No "Know Your Class and Section" data could be extracted for user={username}.', - ) - - logging.info(f'"Know Your Class and Section" data retrieved for user={username}: {kycas}.') - return kycas - async def authenticate( self, username: str, password: str, profile: bool = False, - know_your_class_and_section: bool = False, fields: list[str] | None = None, ) -> dict[str, Any]: """Authenticate the user with the provided username and password. @@ -367,8 +269,6 @@ async def authenticate( username (str): The username of the user, usually their PRN/email/phone number. password (str): The password of the user. profile (bool, optional): Whether to fetch the profile information or not. Defaults to False. - know_your_class_and_section (bool, optional): Whether to fetch from the - "Know Your Class and Section" endpoint or not. Defaults to False. fields (Optional[list[str]], optional): The fields to fetch from the profile. Defaults to None, which means all default fields will be fetched. @@ -435,27 +335,6 @@ async def authenticate( f"Field filtering enabled. Filtered profile data for user={username}: {result['profile']}", ) - 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']}", - ) - logging.info(f"Authentication process for user={username} completed successfully.") # Close the client and return the result diff --git a/scripts/benchmark/benchmark_requests.py b/scripts/benchmark/benchmark_requests.py index 1a26e25..8098da6 100644 --- a/scripts/benchmark/benchmark_requests.py +++ b/scripts/benchmark/benchmark_requests.py @@ -31,12 +31,6 @@ help="Run the authenticate endpoint benchmark without fetching profile information " "(default: fetch profile info)", ) - parser.add_argument( - "--know-your-class-and-section", - action="store_true", - help="Run the authenticate endpoint benchmark with class and section information " - "(default: do not fetch class and section info)", - ) parser.add_argument( "--parallel", action="store_true", @@ -76,7 +70,6 @@ max_workers = args.max_workers num_requests = args.num_requests profile = not args.no_profile - know_your_class_and_section = args.know_your_class_and_section parallel = args.parallel host = args.host route = args.route @@ -95,7 +88,6 @@ executor.submit( make_request, profile=profile, - know_your_class_and_section=know_your_class_and_section, host=host, route=route, timeout=timeout, @@ -119,7 +111,6 @@ for _ in tqdm(range(num_requests), desc="Processing requests"): response, elapsed = make_request( profile=profile, - know_your_class_and_section=know_your_class_and_section, host=host, route=route, timeout=timeout, diff --git a/scripts/benchmark/util.py b/scripts/benchmark/util.py index cb2a524..173856d 100644 --- a/scripts/benchmark/util.py +++ b/scripts/benchmark/util.py @@ -13,7 +13,6 @@ def make_request( host: str = "http://localhost:5000", route: str = "authenticate", profile: bool = True, - know_your_class_and_section: bool = False, timeout: float = 10.0, ) -> tuple[dict, float]: """Make a request to the authentication endpoint and return the response and elapsed time. @@ -22,7 +21,6 @@ def make_request( host: The host to make the request to route: The route to make the request to profile: Whether to fetch the profile information or not - know_your_class_and_section: Whether to fetch class and section information or not timeout: The timeout for the request Returns: @@ -34,7 +32,6 @@ def make_request( "username": os.getenv("TEST_PRN"), "password": os.getenv("TEST_PASSWORD"), "profile": profile, - "knowYourClassAndSection": know_your_class_and_section, } start_time = time.time() response = client.post( diff --git a/tests/functional/test_authenticate_functional.py b/tests/functional/test_authenticate_functional.py index 6554248..f5ccd07 100644 --- a/tests/functional/test_authenticate_functional.py +++ b/tests/functional/test_authenticate_functional.py @@ -166,89 +166,3 @@ async def test_authenticate_invalid_credentials(pesu_academy: PESUAcademy): assert result["status"] is False assert "Invalid username or password" in result["message"] assert "profile" not in result - - -@pytest.mark.secret_required -@pytest.mark.asyncio -async def test_authenticate_with_kycas(pesu_academy: PESUAcademy): - """Test successful authentication with "Know Your Class and Section" data.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - result = await pesu_academy.authenticate( - email, - password, - know_your_class_and_section=True, - fields=None, - ) - assert result["status"] is True - assert "Login successful" in result["message"] - assert "knowYourClassAndSection" in result - kycas = result["knowYourClassAndSection"] - assert "prn" in kycas or "srn" in kycas - assert "name" in kycas - - -@pytest.mark.secret_required -@pytest.mark.asyncio -async def test_authenticate_with_kycas_and_profile(pesu_academy: PESUAcademy): - """Test authentication requesting both profile and "Know Your Class and Section" data.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - result = await pesu_academy.authenticate( - email, - password, - profile=True, - know_your_class_and_section=True, - fields=None, - ) - assert result["status"] is True - assert "profile" in result - assert "knowYourClassAndSection" in result - - -@pytest.mark.secret_required -@pytest.mark.asyncio -async def test_authenticate_with_kycas_field_filtering(pesu_academy: PESUAcademy): - """Test that "Know Your Class and Section" data respects field filtering.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - result = await pesu_academy.authenticate( - email, - password, - 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 - - -@pytest.mark.secret_required -@pytest.mark.asyncio -async def test_authenticate_without_kycas(pesu_academy: PESUAcademy): - """Test that "Know Your Class and Section" data is NOT returned when not requested.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - result = await pesu_academy.authenticate( - email, - password, - know_your_class_and_section=False, - fields=None, - ) - assert result["status"] is True - assert "knowYourClassAndSection" not in result diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 718c0f1..18556e3 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -356,105 +356,6 @@ def test_unhandled_exception_handler(client): assert data["message"] == "Internal Server Error. Please try again later." -def test_integration_authenticate_kycas_wrong_type(client): - """Test that non-boolean knowYourClassAndSection is rejected.""" - payload = { - "username": "username", - "password": "password", - "knowYourClassAndSection": "true", - } - - response = client.post("/authenticate", json=payload) - assert response.status_code == 400 - data = response.json() - assert data["status"] is False - assert "Could not validate request data" in data["message"] - assert "body.knowYourClassAndSection: Input should be a valid boolean" in data["message"] - -@pytest.mark.secret_required -def test_integration_authenticate_with_kycas(client): - """Test successful authentication with "Know Your Class and Section" data.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - payload = { - "username": email, - "password": password, - "knowYourClassAndSection": True, - } - - 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 "knowYourClassAndSection" in data - kycas = data["knowYourClassAndSection"] - assert "prn" in kycas or "srn" in kycas or "name" in kycas - - -@pytest.mark.secret_required -def test_integration_authenticate_with_profile_and_kycas(client): - """Test successful authentication requesting both profile and "Know Your Class and Section".""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - payload = { - "username": email, - "password": password, - "profile": True, - "knowYourClassAndSection": True, - } - - response = client.post("/authenticate", json=payload) - assert response.status_code == 200 - data = response.json() - assert data["status"] is True - assert "profile" in data - assert "knowYourClassAndSection" in data - - -@pytest.mark.secret_required -def test_integration_authenticate_kycas_without_requesting(client): - """Test that "Know Your Class and Section" data is NOT returned when know_your_class_and_section is False.""" - email = os.getenv("TEST_EMAIL") - password = os.getenv("TEST_PASSWORD") - assert email is not None, "TEST_EMAIL environment variable not set" - assert password is not None, "TEST_PASSWORD environment variable not set" - - payload = { - "username": email, - "password": password, - "knowYourClassAndSection": False, - } - - response = client.post("/authenticate", json=payload) - assert response.status_code == 200 - data = response.json() - assert data["status"] is True - assert data.get("knowYourClassAndSection") is None - - -def test_integration_authenticate_deprecated_know_your_class_and_section_key_rejected(client): - """Test that the old snake_case know_your_class_and_section key is rejected with 400.""" - payload = { - "username": "username", - "password": "password", - "know_your_class_and_section": True, - } - - response = client.post("/authenticate", json=payload) - assert response.status_code == 400 - data = response.json() - assert data["status"] is False - assert "Could not validate request data" in data["message"] - assert "body.know_your_class_and_section: Extra inputs are not permitted" in data["message"] - - def test_integration_authenticate_unknown_extra_key_rejected(client): """Test that any unknown key in the request body is rejected with 400.""" payload = { @@ -486,20 +387,3 @@ def test_integration_authenticate_deprecated_campus_code_in_fields_rejected(clie assert data["status"] is False assert "Could not validate request data" in data["message"] assert "body.fields.0" in data["message"] - - -def test_integration_authenticate_deprecated_institute_name_in_fields_rejected(client): - """Test that the old snake_case institute_name is rejected as a fields value.""" - payload = { - "username": "username", - "password": "password", - "profile": True, - "fields": ["institute_name"], - } - - response = client.post("/authenticate", json=payload) - assert response.status_code == 400 - data = response.json() - assert data["status"] is False - assert "Could not validate request data" in data["message"] - assert "body.fields.0" in data["message"] diff --git a/tests/unit/test_pesu.py b/tests/unit/test_pesu.py index cff8c6d..7df74e1 100644 --- a/tests/unit/test_pesu.py +++ b/tests/unit/test_pesu.py @@ -5,7 +5,6 @@ from app.exceptions.authentication import ( AuthenticationError, CSRFTokenError, - KYCASFetchError, ProfileFetchError, ProfileParseError, ) @@ -173,7 +172,7 @@ async def test_authenticate_login_form_present(mock_get, mock_post, mock_html_pa 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_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 = "
" @@ -335,8 +334,8 @@ def css_first(selector): 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_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 @@ -360,8 +359,8 @@ def css_first(selector): 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_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 @@ -389,11 +388,10 @@ async def test_get_profile_information_no_profile_data(mock_get, mock_html_parse 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 "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) - @patch("app.pesu.HTMLParser") @patch("app.pesu.httpx.AsyncClient.get") @patch("app.pesu.PESUAcademy._extract_and_update_profile", new_callable=MagicMock) @@ -491,403 +489,6 @@ def test_default_fields_is_list(): 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") - - 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_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_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_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() - 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", - "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", - "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 - ) - - 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") diff --git a/tests/unit/test_request_model.py b/tests/unit/test_request_model.py index adde8b7..cbc41fb 100644 --- a/tests/unit/test_request_model.py +++ b/tests/unit/test_request_model.py @@ -112,66 +112,16 @@ def test_validate_password_strips_whitespace(): assert model.password == "testpass" -def test_validate_know_your_class_and_section_default_false(): - """Test that know_your_class_and_section defaults to False.""" - model = RequestModel(username="testuser", password="testpass") - assert model.know_your_class_and_section is False - - -def test_validate_know_your_class_and_section_true(): - """Test setting knowYourClassAndSection to True via camelCase alias.""" - model = RequestModel.model_validate({ - "username": "testuser", - "password": "testpass", - "knowYourClassAndSection": True, - }) - assert model.know_your_class_and_section is True - - -def test_validate_know_your_class_and_section_invalid_type(): - """Test that non-boolean types are rejected for knowYourClassAndSection.""" - with pytest.raises(ValidationError) as exc_info: - RequestModel.model_validate({ - "username": "testuser", - "password": "testpass", - "knowYourClassAndSection": "yes", - }) - assert exc_info.value.errors()[0]["type"] == "bool_type" - assert "Input should be a valid boolean" in str(exc_info.value) - - -def test_validate_know_your_class_and_section_int_rejected(): - """Test that integer types are rejected (strict mode).""" - with pytest.raises(ValidationError) as exc_info: - RequestModel.model_validate({ - "username": "testuser", - "password": "testpass", - "knowYourClassAndSection": 1, - }) - assert "Input should be a valid boolean" in str(exc_info.value) - - -def test_validate_deprecated_know_your_class_and_section_key_rejected(): - """Test that the old snake_case know_your_class_and_section key is rejected as an extra field.""" - with pytest.raises(ValidationError) as exc_info: - RequestModel.model_validate({ - "username": "testuser", - "password": "testpass", - "know_your_class_and_section": True, - }) - errors = exc_info.value.errors() - assert any(e["type"] == "extra_forbidden" for e in errors) - assert "Extra inputs are not permitted" in str(exc_info.value) - - def test_validate_unknown_extra_key_rejected(): """Test that any unknown key is rejected.""" with pytest.raises(ValidationError) as exc_info: - RequestModel.model_validate({ - "username": "testuser", - "password": "testpass", - "someRandomField": "value", - }) + RequestModel.model_validate( + { + "username": "testuser", + "password": "testpass", + "someRandomField": "value", + } + ) errors = exc_info.value.errors() assert any(e["type"] == "extra_forbidden" for e in errors) assert "Extra inputs are not permitted" in str(exc_info.value) @@ -184,12 +134,3 @@ def test_validate_deprecated_campus_code_in_fields_rejected(): errors = exc_info.value.errors() assert any(e["type"] == "literal_error" for e in errors) assert "fields.0" in str(exc_info.value) - - -def test_validate_deprecated_institute_name_in_fields_rejected(): - """Test that the old snake_case institute_name is rejected as a fields value.""" - with pytest.raises(ValidationError) as exc_info: - RequestModel(username="testuser", password="testpass", fields=["institute_name"]) - errors = exc_info.value.errors() - assert any(e["type"] == "literal_error" for e in errors) - assert "fields.0" in str(exc_info.value) From feba9a18df01c06af351c9dbb7e2bf0a5bc2e17d Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Tue, 11 Aug 2026 11:50:35 +0530 Subject: [PATCH 2/2] fix: formatting --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b88fd0c..3974b50 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,12 @@ object, with the user's profile information if requested. #### Request Parameters -| **Parameter** | **Optional** | **Type** | **Default** | **Description** | -| ------------------------- | ------------ | ----------- | ----------- | ----------------------------------------------------------------------------------------------- | -| `username` | No | `str` | | The user's SRN or PRN | -| `password` | No | `str` | | The user's password | -| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | -| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | +| **Parameter** | **Optional** | **Type** | **Default** | **Description** | +| ------------- | ------------ | ----------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `username` | No | `str` | | The user's SRN or PRN | +| `password` | No | `str` | | The user's password | +| `profile` | Yes | `boolean` | `False` | Whether to fetch profile information | +| `fields` | Yes | `list[str]` | `None` | Which fields to fetch from the profile information. If not provided, all fields will be fetched | #### Response Object @@ -124,12 +124,12 @@ On authentication, it returns the following parameters in a JSON object. If the profile data was requested, the response's `profile` key will store a dictionary with a user's profile information. **On an unsuccessful sign-in, this field will not exist**. -| **Field** | **Type** | **Description** | -| ------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `status` | `boolean` | A flag indicating whether the overall request was successful | -| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | -| `message` | `str` | A message that provides information corresponding to the status | -| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | +| **Field** | **Type** | **Description** | +| ----------- | --------------- | ------------------------------------------------------------------------ | +| `status` | `boolean` | A flag indicating whether the overall request was successful | +| `profile` | `ProfileObject` | A nested map storing the profile information, returned only if requested | +| `message` | `str` | A message that provides information corresponding to the status | +| `timestamp` | `datetime` | A timezone offset timestamp indicating the time of authentication | ##### `ProfileObject`