From d418959892f8f87b2501f6a23c20ffa4e643308b Mon Sep 17 00:00:00 2001 From: N Digvijay Date: Tue, 11 Aug 2026 11:49:37 +0530 Subject: [PATCH 1/5] 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/5] 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` From 0246480eb5cdbb16d5bac4565a21bd3a736a9cf9 Mon Sep 17 00:00:00 2001 From: aditeyabaral Date: Fri, 11 Sep 2026 23:42:20 -0500 Subject: [PATCH 3/5] test: restore deprecation regression tests for the KYCAS removal The three deprecation tests removed in d418959 were the only coverage pinning that the "Know Your Class and Section" keys are rejected, which is the behaviour #153 asks this PR to guarantee. Each one still passes verbatim against this branch, so restore them: - test_integration_authenticate_deprecated_know_your_class_and_section_key_rejected - test_integration_authenticate_deprecated_institute_name_in_fields_rejected - test_validate_deprecated_know_your_class_and_section_key_rejected Without them the only remaining coverage is the generic unknown-extra-key tests, which would keep passing if knowYourClassAndSection were ever reintroduced into RequestModel. Also add tests for the contracts this PR newly introduces, which were untested: - the camelCase knowYourClassAndSection key is rejected with 400, the literal payload from the issue - the removed KYCAS-only field names (cycle, department, instituteName) are rejected as `fields` values Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ --- tests/integration/test_app_integration.py | 68 +++++++++++++++++++++++ tests/unit/test_request_model.py | 40 +++++++++++++ 2 files changed, 108 insertions(+) diff --git a/tests/integration/test_app_integration.py b/tests/integration/test_app_integration.py index 18556e3..6b1d97e 100644 --- a/tests/integration/test_app_integration.py +++ b/tests/integration/test_app_integration.py @@ -356,6 +356,39 @@ def test_unhandled_exception_handler(client): assert data["message"] == "Internal Server Error. Please try again later." +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_deprecated_know_your_class_and_section_camel_key_rejected(client): + """Test that the deprecated knowYourClassAndSection key is rejected with 400.""" + payload = { + "username": "username", + "password": "password", + "profile": True, + "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: 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 = { @@ -387,3 +420,38 @@ 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"] + + +def test_integration_authenticate_removed_kycas_fields_rejected(client): + """Test that the removed "Know Your Class and Section" field names are rejected with 400.""" + for field in ("cycle", "department", "instituteName"): + payload = { + "username": "username", + "password": "password", + "profile": True, + "fields": [field], + } + + 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_request_model.py b/tests/unit/test_request_model.py index cbc41fb..d6dfc59 100644 --- a/tests/unit/test_request_model.py +++ b/tests/unit/test_request_model.py @@ -112,6 +112,36 @@ def test_validate_password_strips_whitespace(): assert model.password == "testpass" +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_deprecated_know_your_class_and_section_camel_key_rejected(): + """Test that the deprecated knowYourClassAndSection key is rejected as an extra field.""" + with pytest.raises(ValidationError) as exc_info: + RequestModel.model_validate( + { + "username": "testuser", + "password": "testpass", + "knowYourClassAndSection": 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: @@ -134,3 +164,13 @@ 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_removed_kycas_fields_rejected(): + """Test that the removed "Know Your Class and Section" field names are rejected as fields values.""" + for field in ("cycle", "department", "instituteName"): + with pytest.raises(ValidationError) as exc_info: + RequestModel(username="testuser", password="testpass", fields=[field]) + errors = exc_info.value.errors() + assert any(e["type"] == "literal_error" for e in errors) + assert "fields.0" in str(exc_info.value) From b31d835f1018ddaddea1522ce79a322e453ecd74 Mon Sep 17 00:00:00 2001 From: aditeyabaral Date: Fri, 11 Sep 2026 23:42:20 -0500 Subject: [PATCH 4/5] chore!: bump version to 4.0.0 for the breaking KYCAS removal Removing knowYourClassAndSection from the request and response schemas is backward-incompatible, and 3.0.0 is already released and serving in production (main and dev are identical, and /openapi.json on the live deployment reports 3.0.0). Following the precedent of 4e14e18, which shipped the camelCase break as 3.0.0, this goes out as 4.0.0. OpenAPI's info.version is read from the installed package metadata, so without this bump / and /openapi.json would advertise the removal as part of 3.0.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf9bf1c..deae71c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pesu-auth" -version = "3.0.0" +version = "4.0.0" description = "A simple API to authenticate PESU credentials using PESU Academy." readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 26d88a0..5659bb9 100644 --- a/uv.lock +++ b/uv.lock @@ -711,7 +711,7 @@ wheels = [ [[package]] name = "pesu-auth" -version = "3.0.0" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, From 350e66b6d95c7bd8a543549bf6ece63d610a1a42 Mon Sep 17 00:00:00 2001 From: aditeyabaral Date: Fri, 11 Sep 2026 23:47:06 -0500 Subject: [PATCH 5/5] chore: drop dead TEST_BRANCH_SHORT_CODE from .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing has read this variable since the branch_short_code deprecation in 22e3d4b — it is absent from the tests, the benchmark scripts and the TEST_* env list in the pre-commit workflow, so it only misleads anyone filling in a fresh .env. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index 698fb26..5b7b37e 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ TEST_SEMESTER="Sem-8" TEST_SECTION="Section F" TEST_PHONE="********" TEST_BRANCH="Computer Science and Engineering" -TEST_BRANCH_SHORT_CODE="CSE" TEST_CAMPUS="EC" TEST_CAMPUS_CODE=2 TEST_NAME="John Doe"