Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ env/
*.db
.coverage
*.vscode
.env
.env

labconnect/bin
labconnect/include
labconnect/lib
labconnect/pyvenv.cfg
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ posted by professors, graduate students, or lab staff.</p>
```bash
$ python3 -m pytest --cov
```
or individual tests
```bash
$ python3 -m pytest -q tests/(file_name).py -q
```

## Development
* Run flask with python directly
Expand Down
54 changes: 54 additions & 0 deletions labconnect/main/opportunity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,60 @@
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,

Check failure on line 45 in labconnect/main/opportunity_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/opportunity_routes.py:45:89: E501 Line too long (92 > 88)
"year": opportunity.year,
"active": bool(opportunity.active),
}

# Single opportunity endpoints used by the frontend/tests
@main_blueprint.get("/opportunity/<int:opportunity_id>")
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": <int>}."""
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)

return opportunity_to_dict(opp)


@main_blueprint.get("/searchOpportunity/<string:query>")
def searchOpportunity(query: str):
# Perform a search
Expand Down
82 changes: 82 additions & 0 deletions labconnect/main/profile_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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()

Check failure on line 13 in labconnect/main/profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/profile_routes.py:13:89: E501 Line too long (90 > 88)
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:

Check failure on line 39 in labconnect/main/profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/profile_routes.py:39:89: E501 Line too long (99 > 88)
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:

Check failure on line 51 in labconnect/main/profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/profile_routes.py:51:89: E501 Line too long (99 > 88)
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

Check failure on line 69 in labconnect/main/profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/profile_routes.py:69:89: E501 Line too long (93 > 88)
new_user_dept = UserDepartments(user_id=user.id, department_id=dept_id)
db.session.add(new_user_dept)

Check failure on line 71 in labconnect/main/profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

labconnect/main/profile_routes.py:71:89: E501 Line too long (96 > 88)

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"})
1 change: 0 additions & 1 deletion tests/test_opportunity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import pytest
from flask.testing import FlaskClient


def test_get_opportunity_parametrized(test_client: FlaskClient):
"""
GIVEN a Flask application configured for testing
Expand Down
107 changes: 107 additions & 0 deletions tests/test_profile_routes.py
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 25 in tests/test_profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

tests/test_profile_routes.py:25:89: E501 Line too long (89 > 88)

response = test_client.get("/profile")
data = json.loads(response.data)

assert response.status_code == 200
assert data["email"] == "[email protected]"
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")

Check failure on line 41 in tests/test_profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

tests/test_profile_routes.py:41:89: E501 Line too long (104 > 88)
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)

Check failure on line 52 in tests/test_profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

tests/test_profile_routes.py:52:89: E501 Line too long (106 > 88)

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 == "[email protected]")).scalar_one()
assert user.first_name == "UpdatedFirst"
assert user.website == "https://new.example.com"

Check failure on line 72 in tests/test_profile_routes.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (E501)

tests/test_profile_routes.py:72:89: E501 Line too long (95 > 88)
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 == "[email protected]")).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
94 changes: 94 additions & 0 deletions tests/test_single_opportunity_routes.py
Original file line number Diff line number Diff line change
@@ -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
Loading