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