From 2328150cb9ef0f1e3ee6953e215247b2b117c57c Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu <57510269+jaswanthDuddu@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:15:41 -0400 Subject: [PATCH 01/11] API Endpoint: Update opportunity_routes.py new route that queries the database for all opportunities and returns them as a JSON list --- labconnect/main/opportunity_routes.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/labconnect/main/opportunity_routes.py b/labconnect/main/opportunity_routes.py index d38379f1..66ad912b 100644 --- a/labconnect/main/opportunity_routes.py +++ b/labconnect/main/opportunity_routes.py @@ -27,6 +27,30 @@ from . import main_blueprint +@main_blueprint.route("/opportunities", methods=["GET"]) +def get_opportunities(): + """ + Gets all opportunities from the database. + """ + # Query the database to get all opportunity records + opportunities = Opportunity.query.all() + + # Create a list of dictionaries from the opportunity objects + opportunity_list = [ + { + "id": op.id, + "name": op.name, + "description": op.description, + "professor_id": op.professor_id, + "posted_on": op.posted_on.isoformat() # format datetime for JSON + } + for op in opportunities + ] + + # Return the list as a JSON response + return jsonify(opportunity_list), 200 + + @main_blueprint.get("/searchOpportunity/") def searchOpportunity(query: str): # Perform a search From 474d3cffaed2435a319a1fce9b277eb2e6b3fd35 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu <57510269+jaswanthDuddu@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:19:24 -0400 Subject: [PATCH 02/11] API Endpoint: Update test_opportunity.py test to ensure your new endpoint works correctly --- tests/test_opportunity.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index dcc567b4..69c15fda 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -4,6 +4,21 @@ from flask.testing import FlaskClient + +def test_get_all_opportunities(client, auth): + """ + Tests the GET /api/opportunities endpoint to ensure it returns all opportunities. + """ + response = client.get("/api/opportunities") + + assert response.status_code == 200 # request was successful (status code 200) + + assert isinstance(response.json, list) # response is a list + + # atleast 1 opportunity in data, not empty JSON return + assert len(response.json) > 0 + + def test_get_opportunity_parametrized(test_client: FlaskClient): """ GIVEN a Flask application configured for testing From ce80f771a26c7278540da9f1f0fd8131eb24aa35 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu <57510269+jaswanthDuddu@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:55:01 -0400 Subject: [PATCH 03/11] Single Opportunity: Update opportunity_routes.py creating a specific backend route to fetch the details of one opportunity by its ID --- labconnect/main/opportunity_routes.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/labconnect/main/opportunity_routes.py b/labconnect/main/opportunity_routes.py index 66ad912b..d87ec2fb 100644 --- a/labconnect/main/opportunity_routes.py +++ b/labconnect/main/opportunity_routes.py @@ -1,6 +1,6 @@ from datetime import datetime -from flask import abort, request +from flask import abort, request, jsonify from flask_jwt_extended import get_jwt_identity, jwt_required from sqlalchemy import case, func @@ -51,6 +51,20 @@ def get_opportunities(): return jsonify(opportunity_list), 200 +@main_blueprint.get("/opportunity/") +def get_single_opportunity(opportunity_id: int): + """ + Retrieves the details of a single opportunity by its ID. + """ + # Query the database for the opportunity with the given ID + opportunity = db.session.get(Opportunities, opportunity_id) + if opportunity is None: + abort(404, description="Opportunity not found") + + # Serialize the opportunity data and return it as JSON + return jsonify(serialize_opportunity(opportunity)) + + @main_blueprint.get("/searchOpportunity/") def searchOpportunity(query: str): # Perform a search From 0995fce3a6c2a9df0cdbd7c74bea55a21c1fb495 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu <57510269+jaswanthDuddu@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:59:11 -0400 Subject: [PATCH 04/11] Single Opportunity: Update test_opportunity.py test verifies that the new endpoint correctly retrieves an opportunity or handle error --- tests/test_opportunity.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 69c15fda..fbb80be7 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -19,6 +19,33 @@ def test_get_all_opportunities(client, auth): assert len(response.json) > 0 +def test_get_single_opportunity_success(test_client: FlaskClient): + """ + WHEN the '/opportunity/1' endpoint is requested (GET) + THEN check that the response is 200 and contains the correct opportunity data + """ + response = test_client.get("/opportunity/1") # request to the endpoint + + assert response.status_code == 200 + + data = json.loads(response.data) # Parse the JSON response + + # correct opportunity return? + assert data["id"] == 1 + assert data["name"] == "Automated Cooling System" + assert data["pay"] == 15.0 + + +def test_get_single_opportunity_not_found(test_client: FlaskClient): + """ + WHEN the '/opportunity/999' endpoint is requested for a non-existent ID (GET) + THEN check that a 404 Not Found status code is returned + """ + response = test_client.get("/opportunity/999") # request for an opportunity ID DNE + + assert response.status_code == 404 # error check? + + def test_get_opportunity_parametrized(test_client: FlaskClient): """ GIVEN a Flask application configured for testing From 2a3cf8e4f926ed1fb771acaccbbeee233e841b68 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu <57510269+jaswanthDuddu@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:50:05 -0400 Subject: [PATCH 05/11] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 60e550b7..0b93b07b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ posted by professors, graduate students, or lab staff.

```bash $ python3 -m pytest --cov ``` + or individual tests + ```bash + $ python3 -m pytest -q tests/(file_name).py -q + ``` ## Development * Run flask with python directly From 557cb0dca4796a0f21df3e12858ffab78af913b1 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Fri, 10 Oct 2025 15:29:12 -0400 Subject: [PATCH 06/11] Fixes and Improvements for Single and All Opportunity Endpoints Added opportunity_to_dict helper Fix two GET endpoints for better error checks and improved data handling created corresponding tests in test_single_opportunity_routes.py for a smooth experience --- .gitignore | 7 +- labconnect/main/opportunity_routes.py | 78 ++++++++++++-------- tests/test_opportunity.py | 43 ----------- tests/test_single_opportunity_routes.py | 94 +++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 75 deletions(-) create mode 100644 tests/test_single_opportunity_routes.py diff --git a/.gitignore b/.gitignore index cb6c53bb..252b28bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,9 @@ env/ *.db .coverage *.vscode -.env \ No newline at end of file +.env + +labconnect/bin +labconnect/include +labconnect/lib +labconnect/pyvenv.cfg \ No newline at end of file diff --git a/labconnect/main/opportunity_routes.py b/labconnect/main/opportunity_routes.py index d87ec2fb..c7c2e909 100644 --- a/labconnect/main/opportunity_routes.py +++ b/labconnect/main/opportunity_routes.py @@ -1,6 +1,6 @@ from datetime import datetime -from flask import abort, request, jsonify +from flask import abort, request from flask_jwt_extended import get_jwt_identity, jwt_required from sqlalchemy import case, func @@ -27,42 +27,58 @@ from . import main_blueprint -@main_blueprint.route("/opportunities", methods=["GET"]) -def get_opportunities(): - """ - Gets all opportunities from the database. - """ - # Query the database to get all opportunity records - opportunities = Opportunity.query.all() - - # Create a list of dictionaries from the opportunity objects - opportunity_list = [ - { - "id": op.id, - "name": op.name, - "description": op.description, - "professor_id": op.professor_id, - "posted_on": op.posted_on.isoformat() # format datetime for JSON - } - for op in opportunities - ] - - # Return the list as a JSON response - return jsonify(opportunity_list), 200 +def opportunity_to_dict(opportunity: Opportunities) -> dict: + """Return a plain dict representation of an Opportunities model instance.""" + if opportunity is None: + return {} + return { + "id": opportunity.id, + "name": opportunity.name, + "description": opportunity.description, + "recommended_experience": opportunity.recommended_experience, + "pay": opportunity.pay, + "one_credit": bool(opportunity.one_credit), + "two_credits": bool(opportunity.two_credits), + "three_credits": bool(opportunity.three_credits), + "four_credits": bool(opportunity.four_credits), + "semester": str(opportunity.semester) if opportunity.semester is not None else None, + "year": opportunity.year, + "active": bool(opportunity.active), + } +# Single opportunity endpoints used by the frontend/tests @main_blueprint.get("/opportunity/") def get_single_opportunity(opportunity_id: int): + """Return a single opportunity by id. Returns 404 if not found """ - Retrieves the details of a single opportunity by its ID. - """ - # Query the database for the opportunity with the given ID - opportunity = db.session.get(Opportunities, opportunity_id) - if opportunity is None: - abort(404, description="Opportunity not found") + opp = db.session.get(Opportunities, opportunity_id) + if not opp: + abort(404) + + return opportunity_to_dict(opp) + + +@main_blueprint.get("/opportunity") +def get_opportunity_via_json(): + """GET /opportunity expects a JSON payload with {"id": }.""" + data = request.get_json() + if not data: + abort(400) + + if "id" not in data: + abort(400) + + try: + opp_id = int(data["id"]) + except ValueError: + abort(400) + + opp = db.session.get(Opportunities, opp_id) + if not opp: + abort(404) - # Serialize the opportunity data and return it as JSON - return jsonify(serialize_opportunity(opportunity)) + return opportunity_to_dict(opp) @main_blueprint.get("/searchOpportunity/") diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index fbb80be7..8a177813 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -3,49 +3,6 @@ import pytest from flask.testing import FlaskClient - - -def test_get_all_opportunities(client, auth): - """ - Tests the GET /api/opportunities endpoint to ensure it returns all opportunities. - """ - response = client.get("/api/opportunities") - - assert response.status_code == 200 # request was successful (status code 200) - - assert isinstance(response.json, list) # response is a list - - # atleast 1 opportunity in data, not empty JSON return - assert len(response.json) > 0 - - -def test_get_single_opportunity_success(test_client: FlaskClient): - """ - WHEN the '/opportunity/1' endpoint is requested (GET) - THEN check that the response is 200 and contains the correct opportunity data - """ - response = test_client.get("/opportunity/1") # request to the endpoint - - assert response.status_code == 200 - - data = json.loads(response.data) # Parse the JSON response - - # correct opportunity return? - assert data["id"] == 1 - assert data["name"] == "Automated Cooling System" - assert data["pay"] == 15.0 - - -def test_get_single_opportunity_not_found(test_client: FlaskClient): - """ - WHEN the '/opportunity/999' endpoint is requested for a non-existent ID (GET) - THEN check that a 404 Not Found status code is returned - """ - response = test_client.get("/opportunity/999") # request for an opportunity ID DNE - - assert response.status_code == 404 # error check? - - def test_get_opportunity_parametrized(test_client: FlaskClient): """ GIVEN a Flask application configured for testing diff --git a/tests/test_single_opportunity_routes.py b/tests/test_single_opportunity_routes.py new file mode 100644 index 00000000..ca7922d1 --- /dev/null +++ b/tests/test_single_opportunity_routes.py @@ -0,0 +1,94 @@ +import pytest + +from labconnect import db +from labconnect.models import Opportunities +from labconnect.main.opportunity_routes import opportunity_to_dict + + +def test_opportunity_to_dict_none(): + assert opportunity_to_dict(None) == {} + + +def test_opportunity_to_dict_populated(): + # create a lightweight Opportunities instance (no DB persistence needed) + opp = Opportunities() + opp.id = 123 + opp.name = "Unit Test Opportunity" + opp.description = "A test description" + opp.recommended_experience = "Testing" + opp.pay = 7.5 + opp.one_credit = True + opp.two_credits = False + opp.three_credits = False + opp.four_credits = False + opp.semester = None + opp.year = 2025 + opp.active = True + + out = opportunity_to_dict(opp) + + assert out["id"] == 123 + assert out["name"] == "Unit Test Opportunity" + assert out["pay"] == 7.5 + assert out["one_credit"] is True + assert out["two_credits"] is False + assert out["semester"] is None + assert out["year"] == 2025 + + +@pytest.mark.usefixtures("test_client") +def test_get_single_opportunity_not_found(test_client): + response = test_client.get("/opportunity/999999") + assert response.status_code == 404 + + +@pytest.mark.usefixtures("test_client") +def test_get_single_opportunity_success_and_json_variant(test_client): + # create and persist an opportunity to the test database + opp = Opportunities() + opp.name = "Endpoint Test Opportunity" + opp.description = "Endpoint description" + opp.recommended_experience = "None" + opp.pay = 12.0 + opp.one_credit = False + opp.two_credits = True + opp.three_credits = False + opp.four_credits = False + opp.semester = None + opp.year = 2025 + opp.application_due = None + opp.active = True + opp.last_updated = None + opp.location = None + + db.session.add(opp) + db.session.commit() + + # GET by URL id + resp = test_client.get(f"/opportunity/{opp.id}") + assert resp.status_code == 200 + data = resp.get_json() + assert data["id"] == opp.id + assert data["name"] == "Endpoint Test Opportunity" + assert data["pay"] == 12.0 + + # GET via JSON body variant + resp2 = test_client.get("/opportunity", json={"id": opp.id}) + assert resp2.status_code == 200 + data2 = resp2.get_json() + assert data2["id"] == opp.id + + +@pytest.mark.usefixtures("test_client") +def test_get_opportunity_via_json_errors(test_client): + # No JSON -> 400 + resp = test_client.get("/opportunity") + assert resp.status_code in (400, 415) + + # Missing id key -> 400 + resp2 = test_client.get("/opportunity", json={"wrong": "key"}) + assert resp2.status_code == 400 + + # Non-integer id -> 400 + resp3 = test_client.get("/opportunity", json={"id": "not-an-int"}) + assert resp3.status_code == 400 From f9b2025946050f2a5e49f0f9c6399df5d9e424e0 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Tue, 21 Oct 2025 14:09:17 -0400 Subject: [PATCH 07/11] User Profile Management GET /profile route to allow users to fetch their own profile data. Added a 'user_to_dict' helper function to consistently serialize user data. --- labconnect/main/profile_routes.py | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 labconnect/main/profile_routes.py diff --git a/labconnect/main/profile_routes.py b/labconnect/main/profile_routes.py new file mode 100644 index 00000000..48424ec3 --- /dev/null +++ b/labconnect/main/profile_routes.py @@ -0,0 +1,42 @@ +from flask import jsonify, request, Response, make_response +from flask_jwt_extended import jwt_required, get_jwt_identity + +from labconnect import db +from labconnect.models import User, UserDepartments, UserMajors, Departments, Majors +from . import main_blueprint + +def user_to_dict(user: User) -> dict: + """ Helper function to serialize User object data. """ + user_departments = db.session.execute( + db.select(UserDepartments.department_id).where(UserDepartments.user_id == user.id) + ).scalars().all() + + user_majors = db.session.execute( + db.select(UserMajors.major_code).where(UserMajors.user_id == user.id) + ).scalars().all() + + return { + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "preferred_name": user.preferred_name, + "class_year": user.class_year, + "profile_picture": user.profile_picture, + "website": user.website, + "description": user.description, + "departments": user_departments, + "majors": user_majors, + } + +@main_blueprint.route("/profile", methods=["GET"]) +@jwt_required() +def get_profile() -> Response: + """ GET /profile: current user profile """ + user_email = get_jwt_identity() + user = db.session.execute(db.select(User).where(User.email == user_email)).scalar_one_or_none() + + if not user: + return make_response(jsonify({"msg": "User not found"}), 404) + + return jsonify(user_to_dict(user)) \ No newline at end of file From 5e30938ed0dc8019f190395e9c6c2fc9d25a2475 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Tue, 21 Oct 2025 14:14:15 -0400 Subject: [PATCH 08/11] User Profile Management Update PUT /profile route for user to update their personal details. --- labconnect/main/profile_routes.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/labconnect/main/profile_routes.py b/labconnect/main/profile_routes.py index 48424ec3..81c52bd5 100644 --- a/labconnect/main/profile_routes.py +++ b/labconnect/main/profile_routes.py @@ -39,4 +39,30 @@ def get_profile() -> Response: if not user: return make_response(jsonify({"msg": "User not found"}), 404) - return jsonify(user_to_dict(user)) \ No newline at end of file + return jsonify(user_to_dict(user)) + +@main_blueprint.route("/profile", methods=["PUT"]) +@jwt_required() +def update_profile() -> Response: + """ PUT /profile: Updates current user profile """ + user_email = get_jwt_identity() + user = db.session.execute(db.select(User).where(User.email == user_email)).scalar_one_or_none() + + if not user: + return make_response(jsonify({"msg": "User not found"}), 404) + + json_data = request.get_json() + if not json_data: + return make_response(jsonify({"msg": "Missing JSON in request"}), 400) + + # Update basic User fields + user.first_name = json_data.get("first_name", user.first_name) + user.last_name = json_data.get("last_name", user.last_name) + user.preferred_name = json_data.get("preferred_name", user.preferred_name) + user.class_year = json_data.get("class_year", user.class_year) + user.website = json_data.get("website", user.website) + user.description = json_data.get("description", user.description) + + db.session.commit() + + return jsonify({"msg": "Profile updated successfully"}) From 87ff46e07fb7aca1825af67f29276f73bbc59927 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Tue, 21 Oct 2025 16:14:13 -0400 Subject: [PATCH 09/11] User Profile Management Update Majors, Departments PUT /profile route for user to update their majors and department. --- labconnect/main/profile_routes.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/labconnect/main/profile_routes.py b/labconnect/main/profile_routes.py index 81c52bd5..6cc12043 100644 --- a/labconnect/main/profile_routes.py +++ b/labconnect/main/profile_routes.py @@ -2,7 +2,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity from labconnect import db -from labconnect.models import User, UserDepartments, UserMajors, Departments, Majors +from labconnect.models import User, UserDepartments, UserMajors, RPIDepartments, Majors from . import main_blueprint def user_to_dict(user: User) -> dict: @@ -62,6 +62,20 @@ def update_profile() -> Response: user.class_year = json_data.get("class_year", user.class_year) user.website = json_data.get("website", user.website) user.description = json_data.get("description", user.description) + + if "departments" in json_data: + db.session.query(UserDepartments).filter(UserDepartments.user_id == user.id).delete() + for dept_id in json_data["departments"]: + if db.session.get(RPIDepartments, dept_id): # Ensure department exists before adding + new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id) + db.session.add(new_user_dept) + + if "majors" in json_data: + db.session.query(UserMajors).filter(UserMajors.user_id == user.id).delete() + for major_code in json_data["majors"]: + if db.session.get(Majors, major_code): # Ensure major exists before adding + new_user_major = UserMajors(user_id=user.id, major_code=major_code) + db.session.add(new_user_major) db.session.commit() From 24b05902b5e867175ab75221398eaece53ce726c Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Tue, 21 Oct 2025 16:44:42 -0400 Subject: [PATCH 10/11] Tests: User Profile Management endpoints GET /profile endpoint: both successful data retrieval and unauthorized access PUT /profile endpoint: ensuring that full updates, partial updates, and unauthorized attempts are handled correctly. ISSUE: Login helper function to streamline authentication within the tests but the tests fail as user not in DB. --- tests/test_profile_routes.py | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_profile_routes.py diff --git a/tests/test_profile_routes.py b/tests/test_profile_routes.py new file mode 100644 index 00000000..b35edb4d --- /dev/null +++ b/tests/test_profile_routes.py @@ -0,0 +1,107 @@ +import json +from flask.testing import FlaskClient +from labconnect.models import User, UserDepartments, UserMajors +from labconnect import db + + +def login_as_student(test_client: FlaskClient): + """Helper function to log in a user and handle the auth flow.""" + response = test_client.get("/login") + assert response.status_code == 302 + + redirect_url = response.headers['Location'] + code = redirect_url.split('code=')[1] + + token_response = test_client.post("/token", json={"code": code}) + assert token_response.status_code == 200 + + +# === GET /profile Tests === + +def test_get_profile_success(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is requested (GET) -> correct data and 200 status + """ + login_as_student(test_client) + + response = test_client.get("/profile") + data = json.loads(response.data) + + assert response.status_code == 200 + assert data["email"] == "test@rpi.edu" + assert data["first_name"] == "Test" + assert data["last_name"] == "User" + assert "departments" in data + assert "majors" in data + +def test_get_profile_unauthorized(test_client: FlaskClient): + """ + no user is logged in: '/profile' endpoint is requested (GET) -> 401 Unauthorized status is returned. + """ + test_client.get("/logout") + response = test_client.get("/profile") + assert response.status_code == 401 + + +# === PUT /profile Tests === + +def test_update_profile_success(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is updated with new data (PUT) -> 200 status and database changed. + """ + login_as_student(test_client) + + update_data = { + "first_name": "UpdatedFirst", + "last_name": "UpdatedLast", + "preferred_name": "Pref", + "class_year": 2025, + "website": "https://new.example.com", + "description": "This is an updated description.", + "departments": ["CS"], + "majors": ["CSCI", "MATH"] + } + + response = test_client.put("/profile", json=update_data) + assert response.status_code == 200 + assert "Profile updated successfully" in json.loads(response.data)["msg"] + + # Verify the changes in the database + user = db.session.execute(db.select(User).where(User.email == "test@rpi.edu")).scalar_one() + assert user.first_name == "UpdatedFirst" + assert user.website == "https://new.example.com" + assert user.class_year == 2025 + + user_depts = db.session.execute(db.select(UserDepartments.department_id).where(UserDepartments.user_id == user.id)).scalars().all() + assert set(user_depts) == {"CS"} + + user_majors = db.session.execute(db.select(UserMajors.major_code).where(UserMajors.user_id == user.id)).scalars().all() + assert set(user_majors) == {"CSCI", "MATH"} + +def test_update_profile_partial(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is updated with partial data (PUT) -> check only provided fields updated. + """ + login_as_student(test_client) + + update_data = { + "website": "https://partial.update.com", + "description": "Only this was updated." + } + + response = test_client.put("/profile", json=update_data) + assert response.status_code == 200 + + user = db.session.execute(db.select(User).where(User.email == "test@rpi.edu")).scalar_one() + assert user.website == "https://partial.update.com" + assert user.description == "Only this was updated." + assert user.last_name == "User" + +def test_update_profile_unauthorized(test_client: FlaskClient): + """ + no user is logged in: '/profile' endpoint is sent a PUT request -> 401 Unauthorized status. + """ + test_client.get("/logout") + update_data = {"first_name": "ShouldFail"} + response = test_client.put("/profile", json=update_data) + assert response.status_code == 401 \ No newline at end of file From dcc07ff53e0eb4cf1ca89f0322217bd36b0b7332 Mon Sep 17 00:00:00 2001 From: Jaswanth Duddu Date: Fri, 24 Oct 2025 16:29:58 -0400 Subject: [PATCH 11/11] Fixing Ruff Linter Errors --- labconnect/main/opportunity_routes.py | 8 +- labconnect/main/profile_routes.py | 186 ++++++++++-------- tests/test_opportunity.py | 1 + tests/test_profile_routes.py | 244 +++++++++++++----------- tests/test_single_opportunity_routes.py | 188 +++++++++--------- 5 files changed, 341 insertions(+), 286 deletions(-) diff --git a/labconnect/main/opportunity_routes.py b/labconnect/main/opportunity_routes.py index a7b574b3..d2818f4c 100644 --- a/labconnect/main/opportunity_routes.py +++ b/labconnect/main/opportunity_routes.py @@ -42,16 +42,18 @@ def opportunity_to_dict(opportunity: Opportunities) -> dict: "two_credits": bool(opportunity.two_credits), "three_credits": bool(opportunity.three_credits), "four_credits": bool(opportunity.four_credits), - "semester": str(opportunity.semester) if opportunity.semester is not None else None, + "semester": str(opportunity.semester) + if opportunity.semester is not None + else None, "year": opportunity.year, "active": bool(opportunity.active), } + # Single opportunity endpoints used by the frontend/tests @main_blueprint.get("/opportunity/") def get_single_opportunity(opportunity_id: int): - """Return a single opportunity by id. Returns 404 if not found - """ + """Return a single opportunity by id. Returns 404 if not found""" opp = db.session.get(Opportunities, opportunity_id) if not opp: abort(404) diff --git a/labconnect/main/profile_routes.py b/labconnect/main/profile_routes.py index 6cc12043..35820267 100644 --- a/labconnect/main/profile_routes.py +++ b/labconnect/main/profile_routes.py @@ -1,82 +1,104 @@ -from flask import jsonify, request, Response, make_response -from flask_jwt_extended import jwt_required, get_jwt_identity - -from labconnect import db -from labconnect.models import User, UserDepartments, UserMajors, RPIDepartments, Majors -from . import main_blueprint - -def user_to_dict(user: User) -> dict: - """ Helper function to serialize User object data. """ - user_departments = db.session.execute( - db.select(UserDepartments.department_id).where(UserDepartments.user_id == user.id) - ).scalars().all() - - user_majors = db.session.execute( - db.select(UserMajors.major_code).where(UserMajors.user_id == user.id) - ).scalars().all() - - return { - "id": user.id, - "email": user.email, - "first_name": user.first_name, - "last_name": user.last_name, - "preferred_name": user.preferred_name, - "class_year": user.class_year, - "profile_picture": user.profile_picture, - "website": user.website, - "description": user.description, - "departments": user_departments, - "majors": user_majors, - } - -@main_blueprint.route("/profile", methods=["GET"]) -@jwt_required() -def get_profile() -> Response: - """ GET /profile: current user profile """ - user_email = get_jwt_identity() - user = db.session.execute(db.select(User).where(User.email == user_email)).scalar_one_or_none() - - if not user: - return make_response(jsonify({"msg": "User not found"}), 404) - - return jsonify(user_to_dict(user)) - -@main_blueprint.route("/profile", methods=["PUT"]) -@jwt_required() -def update_profile() -> Response: - """ PUT /profile: Updates current user profile """ - user_email = get_jwt_identity() - user = db.session.execute(db.select(User).where(User.email == user_email)).scalar_one_or_none() - - if not user: - return make_response(jsonify({"msg": "User not found"}), 404) - - json_data = request.get_json() - if not json_data: - return make_response(jsonify({"msg": "Missing JSON in request"}), 400) - - # Update basic User fields - user.first_name = json_data.get("first_name", user.first_name) - user.last_name = json_data.get("last_name", user.last_name) - user.preferred_name = json_data.get("preferred_name", user.preferred_name) - user.class_year = json_data.get("class_year", user.class_year) - user.website = json_data.get("website", user.website) - user.description = json_data.get("description", user.description) - - if "departments" in json_data: - db.session.query(UserDepartments).filter(UserDepartments.user_id == user.id).delete() - for dept_id in json_data["departments"]: - if db.session.get(RPIDepartments, dept_id): # Ensure department exists before adding - new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id) - db.session.add(new_user_dept) - - if "majors" in json_data: - db.session.query(UserMajors).filter(UserMajors.user_id == user.id).delete() - for major_code in json_data["majors"]: - if db.session.get(Majors, major_code): # Ensure major exists before adding - new_user_major = UserMajors(user_id=user.id, major_code=major_code) - db.session.add(new_user_major) - - db.session.commit() - - return jsonify({"msg": "Profile updated successfully"}) +from flask import Response, jsonify, make_response, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from labconnect import db +from labconnect.models import Majors, RPIDepartments, User, UserDepartments, UserMajors + +from . import main_blueprint + + +def user_to_dict(user: User) -> dict: + """Helper function to serialize User object data.""" + user_departments = ( + db.session.execute( + db.select(UserDepartments.department_id).where( + UserDepartments.user_id == user.id + ) + ) + .scalars() + .all() + ) + + user_majors = ( + db.session.execute( + db.select(UserMajors.major_code).where(UserMajors.user_id == user.id) + ) + .scalars() + .all() + ) + + return { + "id": user.id, + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "preferred_name": user.preferred_name, + "class_year": user.class_year, + "profile_picture": user.profile_picture, + "website": user.website, + "description": user.description, + "departments": user_departments, + "majors": user_majors, + } + + +@main_blueprint.route("/profile", methods=["GET"]) +@jwt_required() +def get_profile() -> Response: + """GET /profile: current user profile""" + user_email = get_jwt_identity() + user = db.session.execute( + db.select(User).where(User.email == user_email) + ).scalar_one_or_none() + + if not user: + return make_response(jsonify({"msg": "User not found"}), 404) + + return jsonify(user_to_dict(user)) + + +@main_blueprint.route("/profile", methods=["PUT"]) +@jwt_required() +def update_profile() -> Response: + """PUT /profile: Updates current user profile""" + user_email = get_jwt_identity() + user = db.session.execute( + db.select(User).where(User.email == user_email) + ).scalar_one_or_none() + + if not user: + return make_response(jsonify({"msg": "User not found"}), 404) + + json_data = request.get_json() + if not json_data: + return make_response(jsonify({"msg": "Missing JSON in request"}), 400) + + # Update basic User fields + user.first_name = json_data.get("first_name", user.first_name) + user.last_name = json_data.get("last_name", user.last_name) + user.preferred_name = json_data.get("preferred_name", user.preferred_name) + user.class_year = json_data.get("class_year", user.class_year) + user.website = json_data.get("website", user.website) + user.description = json_data.get("description", user.description) + + if "departments" in json_data: + db.session.query(UserDepartments).filter( + UserDepartments.user_id == user.id + ).delete() + for dept_id in json_data["departments"]: + if db.session.get( + RPIDepartments, dept_id + ): # Ensure department exists before adding + new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id) + db.session.add(new_user_dept) + + if "majors" in json_data: + db.session.query(UserMajors).filter(UserMajors.user_id == user.id).delete() + for major_code in json_data["majors"]: + if db.session.get(Majors, major_code): # Ensure major exists before adding + new_user_major = UserMajors(user_id=user.id, major_code=major_code) + db.session.add(new_user_major) + + db.session.commit() + + return jsonify({"msg": "Profile updated successfully"}) diff --git a/tests/test_opportunity.py b/tests/test_opportunity.py index 8a177813..dcc567b4 100644 --- a/tests/test_opportunity.py +++ b/tests/test_opportunity.py @@ -3,6 +3,7 @@ import pytest from flask.testing import FlaskClient + def test_get_opportunity_parametrized(test_client: FlaskClient): """ GIVEN a Flask application configured for testing diff --git a/tests/test_profile_routes.py b/tests/test_profile_routes.py index b35edb4d..644aaee9 100644 --- a/tests/test_profile_routes.py +++ b/tests/test_profile_routes.py @@ -1,107 +1,137 @@ -import json -from flask.testing import FlaskClient -from labconnect.models import User, UserDepartments, UserMajors -from labconnect import db - - -def login_as_student(test_client: FlaskClient): - """Helper function to log in a user and handle the auth flow.""" - response = test_client.get("/login") - assert response.status_code == 302 - - redirect_url = response.headers['Location'] - code = redirect_url.split('code=')[1] - - token_response = test_client.post("/token", json={"code": code}) - assert token_response.status_code == 200 - - -# === GET /profile Tests === - -def test_get_profile_success(test_client: FlaskClient): - """ - logged-in user: '/profile' endpoint is requested (GET) -> correct data and 200 status - """ - login_as_student(test_client) - - response = test_client.get("/profile") - data = json.loads(response.data) - - assert response.status_code == 200 - assert data["email"] == "test@rpi.edu" - assert data["first_name"] == "Test" - assert data["last_name"] == "User" - assert "departments" in data - assert "majors" in data - -def test_get_profile_unauthorized(test_client: FlaskClient): - """ - no user is logged in: '/profile' endpoint is requested (GET) -> 401 Unauthorized status is returned. - """ - test_client.get("/logout") - response = test_client.get("/profile") - assert response.status_code == 401 - - -# === PUT /profile Tests === - -def test_update_profile_success(test_client: FlaskClient): - """ - logged-in user: '/profile' endpoint is updated with new data (PUT) -> 200 status and database changed. - """ - login_as_student(test_client) - - update_data = { - "first_name": "UpdatedFirst", - "last_name": "UpdatedLast", - "preferred_name": "Pref", - "class_year": 2025, - "website": "https://new.example.com", - "description": "This is an updated description.", - "departments": ["CS"], - "majors": ["CSCI", "MATH"] - } - - response = test_client.put("/profile", json=update_data) - assert response.status_code == 200 - assert "Profile updated successfully" in json.loads(response.data)["msg"] - - # Verify the changes in the database - user = db.session.execute(db.select(User).where(User.email == "test@rpi.edu")).scalar_one() - assert user.first_name == "UpdatedFirst" - assert user.website == "https://new.example.com" - assert user.class_year == 2025 - - user_depts = db.session.execute(db.select(UserDepartments.department_id).where(UserDepartments.user_id == user.id)).scalars().all() - assert set(user_depts) == {"CS"} - - user_majors = db.session.execute(db.select(UserMajors.major_code).where(UserMajors.user_id == user.id)).scalars().all() - assert set(user_majors) == {"CSCI", "MATH"} - -def test_update_profile_partial(test_client: FlaskClient): - """ - logged-in user: '/profile' endpoint is updated with partial data (PUT) -> check only provided fields updated. - """ - login_as_student(test_client) - - update_data = { - "website": "https://partial.update.com", - "description": "Only this was updated." - } - - response = test_client.put("/profile", json=update_data) - assert response.status_code == 200 - - user = db.session.execute(db.select(User).where(User.email == "test@rpi.edu")).scalar_one() - assert user.website == "https://partial.update.com" - assert user.description == "Only this was updated." - assert user.last_name == "User" - -def test_update_profile_unauthorized(test_client: FlaskClient): - """ - no user is logged in: '/profile' endpoint is sent a PUT request -> 401 Unauthorized status. - """ - test_client.get("/logout") - update_data = {"first_name": "ShouldFail"} - response = test_client.put("/profile", json=update_data) - assert response.status_code == 401 \ No newline at end of file +import json + +from flask.testing import FlaskClient + +from labconnect import db +from labconnect.models import User, UserDepartments, UserMajors + + +def login_as_student(test_client: FlaskClient): + """Helper function to log in a user and handle the auth flow.""" + response = test_client.get("/login") + assert response.status_code == 302 + + redirect_url = response.headers["Location"] + code = redirect_url.split("code=")[1] + + token_response = test_client.post("/token", json={"code": code}) + assert token_response.status_code == 200 + + +# === GET /profile Tests === + + +def test_get_profile_success(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is requested (GET) + -> correct data and 200 status + """ + login_as_student(test_client) + + response = test_client.get("/profile") + data = json.loads(response.data) + + assert response.status_code == 200 + assert data["email"] == "test@rpi.edu" + assert data["first_name"] == "Test" + assert data["last_name"] == "User" + assert "departments" in data + assert "majors" in data + + +def test_get_profile_unauthorized(test_client: FlaskClient): + """ + no user is logged in: '/profile' endpoint is requested (GET) + -> 401 Unauthorized status is returned. + """ + test_client.get("/logout") + response = test_client.get("/profile") + assert response.status_code == 401 + + +# === PUT /profile Tests === + + +def test_update_profile_success(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is updated with new data (PUT) + -> 200 status and database changed. + """ + login_as_student(test_client) + + update_data = { + "first_name": "UpdatedFirst", + "last_name": "UpdatedLast", + "preferred_name": "Pref", + "class_year": 2025, + "website": "https://new.example.com", + "description": "This is an updated description.", + "departments": ["CS"], + "majors": ["CSCI", "MATH"], + } + + response = test_client.put("/profile", json=update_data) + assert response.status_code == 200 + assert "Profile updated successfully" in json.loads(response.data)["msg"] + + # Verify the changes in the database + user = db.session.execute( + db.select(User).where(User.email == "test@rpi.edu") + ).scalar_one() + assert user.first_name == "UpdatedFirst" + assert user.website == "https://new.example.com" + assert user.class_year == 2025 + + user_depts = ( + db.session.execute( + db.select(UserDepartments.department_id).where( + UserDepartments.user_id == user.id + ) + ) + .scalars() + .all() + ) + assert set(user_depts) == {"CS"} + + user_majors = ( + db.session.execute( + db.select(UserMajors.major_code).where(UserMajors.user_id == user.id) + ) + .scalars() + .all() + ) + assert set(user_majors) == {"CSCI", "MATH"} + + +def test_update_profile_partial(test_client: FlaskClient): + """ + logged-in user: '/profile' endpoint is updated with partial data (PUT) + -> check only provided fields updated. + """ + login_as_student(test_client) + + update_data = { + "website": "https://partial.update.com", + "description": "Only this was updated.", + } + + response = test_client.put("/profile", json=update_data) + assert response.status_code == 200 + + user = db.session.execute( + db.select(User).where(User.email == "test@rpi.edu") + ).scalar_one() + assert user.website == "https://partial.update.com" + assert user.description == "Only this was updated." + assert user.last_name == "User" + + +def test_update_profile_unauthorized(test_client: FlaskClient): + """ + no user is logged in: '/profile' endpoint is sent a PUT request + -> 401 Unauthorized status. + """ + test_client.get("/logout") + update_data = {"first_name": "ShouldFail"} + response = test_client.put("/profile", json=update_data) + assert response.status_code == 401 diff --git a/tests/test_single_opportunity_routes.py b/tests/test_single_opportunity_routes.py index ca7922d1..e5fea110 100644 --- a/tests/test_single_opportunity_routes.py +++ b/tests/test_single_opportunity_routes.py @@ -1,94 +1,94 @@ -import pytest - -from labconnect import db -from labconnect.models import Opportunities -from labconnect.main.opportunity_routes import opportunity_to_dict - - -def test_opportunity_to_dict_none(): - assert opportunity_to_dict(None) == {} - - -def test_opportunity_to_dict_populated(): - # create a lightweight Opportunities instance (no DB persistence needed) - opp = Opportunities() - opp.id = 123 - opp.name = "Unit Test Opportunity" - opp.description = "A test description" - opp.recommended_experience = "Testing" - opp.pay = 7.5 - opp.one_credit = True - opp.two_credits = False - opp.three_credits = False - opp.four_credits = False - opp.semester = None - opp.year = 2025 - opp.active = True - - out = opportunity_to_dict(opp) - - assert out["id"] == 123 - assert out["name"] == "Unit Test Opportunity" - assert out["pay"] == 7.5 - assert out["one_credit"] is True - assert out["two_credits"] is False - assert out["semester"] is None - assert out["year"] == 2025 - - -@pytest.mark.usefixtures("test_client") -def test_get_single_opportunity_not_found(test_client): - response = test_client.get("/opportunity/999999") - assert response.status_code == 404 - - -@pytest.mark.usefixtures("test_client") -def test_get_single_opportunity_success_and_json_variant(test_client): - # create and persist an opportunity to the test database - opp = Opportunities() - opp.name = "Endpoint Test Opportunity" - opp.description = "Endpoint description" - opp.recommended_experience = "None" - opp.pay = 12.0 - opp.one_credit = False - opp.two_credits = True - opp.three_credits = False - opp.four_credits = False - opp.semester = None - opp.year = 2025 - opp.application_due = None - opp.active = True - opp.last_updated = None - opp.location = None - - db.session.add(opp) - db.session.commit() - - # GET by URL id - resp = test_client.get(f"/opportunity/{opp.id}") - assert resp.status_code == 200 - data = resp.get_json() - assert data["id"] == opp.id - assert data["name"] == "Endpoint Test Opportunity" - assert data["pay"] == 12.0 - - # GET via JSON body variant - resp2 = test_client.get("/opportunity", json={"id": opp.id}) - assert resp2.status_code == 200 - data2 = resp2.get_json() - assert data2["id"] == opp.id - - -@pytest.mark.usefixtures("test_client") -def test_get_opportunity_via_json_errors(test_client): - # No JSON -> 400 - resp = test_client.get("/opportunity") - assert resp.status_code in (400, 415) - - # Missing id key -> 400 - resp2 = test_client.get("/opportunity", json={"wrong": "key"}) - assert resp2.status_code == 400 - - # Non-integer id -> 400 - resp3 = test_client.get("/opportunity", json={"id": "not-an-int"}) - assert resp3.status_code == 400 +import pytest + +from labconnect import db +from labconnect.main.opportunity_routes import opportunity_to_dict +from labconnect.models import Opportunities + + +def test_opportunity_to_dict_none(): + assert opportunity_to_dict(None) == {} + + +def test_opportunity_to_dict_populated(): + # create a lightweight Opportunities instance (no DB persistence needed) + opp = Opportunities() + opp.id = 123 + opp.name = "Unit Test Opportunity" + opp.description = "A test description" + opp.recommended_experience = "Testing" + opp.pay = 7.5 + opp.one_credit = True + opp.two_credits = False + opp.three_credits = False + opp.four_credits = False + opp.semester = None + opp.year = 2025 + opp.active = True + + out = opportunity_to_dict(opp) + + assert out["id"] == 123 + assert out["name"] == "Unit Test Opportunity" + assert out["pay"] == 7.5 + assert out["one_credit"] is True + assert out["two_credits"] is False + assert out["semester"] is None + assert out["year"] == 2025 + + +@pytest.mark.usefixtures("test_client") +def test_get_single_opportunity_not_found(test_client): + response = test_client.get("/opportunity/999999") + assert response.status_code == 404 + + +@pytest.mark.usefixtures("test_client") +def test_get_single_opportunity_success_and_json_variant(test_client): + # create and persist an opportunity to the test database + opp = Opportunities() + opp.name = "Endpoint Test Opportunity" + opp.description = "Endpoint description" + opp.recommended_experience = "None" + opp.pay = 12.0 + opp.one_credit = False + opp.two_credits = True + opp.three_credits = False + opp.four_credits = False + opp.semester = None + opp.year = 2025 + opp.application_due = None + opp.active = True + opp.last_updated = None + opp.location = None + + db.session.add(opp) + db.session.commit() + + # GET by URL id + resp = test_client.get(f"/opportunity/{opp.id}") + assert resp.status_code == 200 + data = resp.get_json() + assert data["id"] == opp.id + assert data["name"] == "Endpoint Test Opportunity" + assert data["pay"] == 12.0 + + # GET via JSON body variant + resp2 = test_client.get("/opportunity", json={"id": opp.id}) + assert resp2.status_code == 200 + data2 = resp2.get_json() + assert data2["id"] == opp.id + + +@pytest.mark.usefixtures("test_client") +def test_get_opportunity_via_json_errors(test_client): + # No JSON -> 400 + resp = test_client.get("/opportunity") + assert resp.status_code in (400, 415) + + # Missing id key -> 400 + resp2 = test_client.get("/opportunity", json={"wrong": "key"}) + assert resp2.status_code == 400 + + # Non-integer id -> 400 + resp3 = test_client.get("/opportunity", json={"id": "not-an-int"}) + assert resp3.status_code == 400