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/README.md b/README.md index ff20a030..c128c106 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,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 + ``` ## Development * Run flask with python directly diff --git a/labconnect/main/__init__.py b/labconnect/main/__init__.py index 96819d8f..e6720032 100644 --- a/labconnect/main/__init__.py +++ b/labconnect/main/__init__.py @@ -6,4 +6,4 @@ main_blueprint = Blueprint("main", __name__) -from . import auth_routes, opportunity_routes, routes # noqa +from . import auth_routes, opportunity_routes, routes, profile_routes # noqa diff --git a/labconnect/main/opportunity_routes.py b/labconnect/main/opportunity_routes.py index b106d7c5..4a6fcd79 100644 --- a/labconnect/main/opportunity_routes.py +++ b/labconnect/main/opportunity_routes.py @@ -27,6 +27,58 @@ from . import main_blueprint +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""" + 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() + opp_id_raw = data.get("id") if data else None + + try: + opp_id = int(opp_id_raw) + except (ValueError, TypeError): + abort(400, description="Invalid or missing 'id' in JSON payload.") + + opp = db.session.get(Opportunities, opp_id) + if not opp: + abort(404, description="Opportunity not found.") + + return opportunity_to_dict(opp) + + @main_blueprint.get("/searchOpportunity/") def searchOpportunity(query: str): # Perform a search diff --git a/labconnect/main/profile_routes.py b/labconnect/main/profile_routes.py new file mode 100644 index 00000000..f9d9f1eb --- /dev/null +++ b/labconnect/main/profile_routes.py @@ -0,0 +1,118 @@ +from flask import Response, jsonify, 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 {"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 {"msg": "User not found"}, 404 + + json_data = request.get_json() + if not json_data: + return {"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.execute( + db.delete(UserDepartments).where(UserDepartments.user_id == user.id) + ) + + req_dept_ids = set(json_data["departments"]) + if req_dept_ids: # Only query if list is not empty + valid_dept_ids = db.session.execute( + db.select(RPIDepartments.id).where( + RPIDepartments.id.in_(req_dept_ids) + ) + ).scalars().all() + + for dept_id in valid_dept_ids: # Add only the valid ones + 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.execute( + db.delete(UserMajors).where(UserMajors.user_id == user.id) + ) + + req_major_codes = set(json_data["majors"]) + if req_major_codes: # Only query if list is not empty + valid_major_codes = db.session.execute( + db.select(Majors.code).where(Majors.code.in_(req_major_codes)) + ).scalars().all() + + for major_code in valid_major_codes: # Add only the valid ones + new_user_major = UserMajors(user_id=user.id, major_code=major_code) + db.session.add(new_user_major) + + db.session.commit() + + return {"msg": "Profile updated successfully"} diff --git a/tests/test_profile_routes.py b/tests/test_profile_routes.py new file mode 100644 index 00000000..644aaee9 --- /dev/null +++ b/tests/test_profile_routes.py @@ -0,0 +1,137 @@ +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 new file mode 100644 index 00000000..977e0ef4 --- /dev/null +++ b/tests/test_single_opportunity_routes.py @@ -0,0 +1,99 @@ +import pytest + + +def test_opportunity_to_dict_none(): + from importlib import import_module + opp_mod = import_module("labconnect.main.opportunity_routes") + assert opp_mod.opportunity_to_dict(None) == {} + + +def test_opportunity_to_dict_populated(): + # create a lightweight Opportunities instance (no DB persistence needed) + from labconnect.models import Opportunities + + 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 + + from importlib import import_module + opp_mod = import_module("labconnect.main.opportunity_routes") + out = opp_mod.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 + from labconnect import db + from labconnect.models import Opportunities + + 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