Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
65 changes: 33 additions & 32 deletions src/routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ def index():
stats = get_project_stats()
available_levels = get_available_levels()
except Exception as e:
# In development, we prefer rendering a fallback homepage rather than
# aborting entirely. Log the error and use safe defaults so UI/layout
# checks can proceed.
print("Warning: failed to load project stats:", e)
stats = {"total_projects": 0, "unique_skills": 0, "beginner_friendly": 0}
available_levels = ["Beginner", "Intermediate", "Advanced"]
Expand Down Expand Up @@ -115,7 +112,6 @@ def recommend():
if not payload:
return jsonify({"error": "Request body must be valid JSON."}), 400

# Reject non-string values (e.g. null, lists, numbers) before calling .strip()
string_fields = ("skills", "level", "interest", "time")
for field in string_fields:
value = payload.get(field)
Expand All @@ -127,10 +123,8 @@ def recommend():
interest = (payload.get("interest") or "").strip()
time_availability = (payload.get("time") or "").strip()

# Validate before running the recommendation engine
errors = validate_recommendation_inputs(skills, level, interest, time_availability)
if errors:
# Return only the first error to keep the UI message clean
return jsonify({"error": errors[0]}), 400

if interest_has_no_projects(interest):
Expand All @@ -151,16 +145,13 @@ def recommend():
)
}), 200

# Ensure all projects have IDs in the response
projects_data = []
for project in results:
project_dict = dict(project) # Convert to dict if needed
# Make sure ID is included
project_dict = dict(project)
if 'id' not in project_dict:
project_dict['id'] = project.get('id', 0)
projects_data.append(project_dict)

# Return main recommendations, related, and progression
response_data = {
"projects": projects_data,
"related": [dict(p) for p in recommendations_data.get("related", [])],
Expand All @@ -174,21 +165,7 @@ def recommend():

@main.route("/api/project/<int:project_id>/resources")
def project_resources(project_id):
"""Return the validated resource list for a project.

Each resource is parsed from its raw "Label: URL" string format and
returned as a structured object so the frontend can render broken
links differently from valid ones.

Response shape:
{
"project_id": 1,
"resources": [
{"label": "Python official docs", "url": "https://docs.python.org", "valid": true},
{"label": "Broken link", "url": "not-a-url", "valid": false}
]
}
"""
"""Return the validated resource list for a project."""
from utils.url_validator import validate_resources

project = find_project_by_id(project_id)
Expand Down Expand Up @@ -244,7 +221,6 @@ def download_code(project_id):
def sitemap():
"""
Generate and return a sitemap.xml for search engine indexing.
Includes the homepage and all individual project detail pages.
"""
base = request.host_url.rstrip("/")
projects = load_all_projects()
Expand Down Expand Up @@ -282,7 +258,6 @@ def search_projects():

for project in projects:

# Combine searchable project fields into one lowercase string
searchable_text = " ".join([
project.get("title", ""),
project.get("description", ""),
Expand All @@ -298,7 +273,6 @@ def search_projects():
return jsonify(filtered_projects)


# ---------------------------------------------------------------------------
# Learning path API
#
# Endpoints for reading and writing a user's learning path data. Every
Expand All @@ -309,7 +283,9 @@ def search_projects():
#
# Token transport: the X-Learning-Path-Token request header.
# Path identity: the <path_id> URL segment (opaque, UUID-like string).
# ---------------------------------------------------------------------------
#
# Payload size: requests are also rejected with 400 if the raw body
# exceeds _MAX_DATA_BYTES, enforced by _payload_too_large() below.

_TOKEN_HEADER = "X-Learning-Path-Token"
_MAX_DATA_BYTES = 64 * 1024 # 64 KB — guard against oversized payloads
Expand All @@ -320,6 +296,17 @@ def _extract_token(req):
return req.headers.get(_TOKEN_HEADER, "").strip() or None


def _payload_too_large(req):
"""
Return True if the raw request body exceeds _MAX_DATA_BYTES.

Checked against the raw body (req.get_data()) rather than the parsed
JSON so that oversized requests are rejected before the (potentially
expensive) JSON parse even happens.
"""
return len(req.get_data()) > _MAX_DATA_BYTES


@main.route("/api/learning-path/<path_id>", methods=["POST"])
def create_path(path_id):
"""Create a new learning path and bind it to the supplied token.
Expand All @@ -330,15 +317,22 @@ def create_path(path_id):

Request body (JSON):
Any JSON object representing the initial learning-path state.
Must not exceed _MAX_DATA_BYTES (64 KB).

Response 201: {"path_id": "<path_id>", "message": "Learning path created."}
Response 400: malformed request body or invalid path_id / token format.
Response 400: malformed request body, invalid path_id / token format,
or payload exceeds the size limit.
Response 409: a learning path with this path_id already exists.
"""
token = _extract_token(request)
if not token:
return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400

if _payload_too_large(request):
return jsonify({
"error": f"Request payload exceeds the {_MAX_DATA_BYTES // 1024}KB size limit."
}), 400

payload = request.get_json(silent=True)
if payload is None:
return jsonify({"error": "Request body must be valid JSON."}), 400
Expand Down Expand Up @@ -395,16 +389,23 @@ def update_path(path_id):

Request body (JSON):
Any JSON object representing the new learning-path state.
Must not exceed _MAX_DATA_BYTES (64 KB).

Response 200: {"path_id": "<path_id>", "message": "Learning path updated."}
Response 400: malformed request body, missing token, or invalid format.
Response 400: malformed request body, missing token, invalid format,
or payload exceeds the size limit.
Response 403: token does not match the owner token.
Response 404: no learning path found for this path_id.
"""
token = _extract_token(request)
if not token:
return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400

if _payload_too_large(request):
return jsonify({
"error": f"Request payload exceeds the {_MAX_DATA_BYTES // 1024}KB size limit."
}), 400

payload = request.get_json(silent=True)
if payload is None:
return jsonify({"error": "Request body must be valid JSON."}), 400
Expand All @@ -421,4 +422,4 @@ def update_path(path_id):
except AuthorizationError:
return jsonify({"error": "Forbidden: invalid token for this path."}), 403

return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200
return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200
102 changes: 87 additions & 15 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,15 @@
from app import app, internal_server_error


# ============================================================
# Test setup
# ============================================================

def setup_module():
"""Clear the data cache before running the test suite to ensure clean state."""
clear_cache()
clear_roadmap_cache()


# ============================================================
# Data loader tests
# ============================================================

def test_projects_json_loads():
"""The data file must exist and contain at least one project."""
Expand Down Expand Up @@ -186,9 +182,7 @@ def test_find_project_by_id_missing():
assert result is None, "Expected None for a non-existent project ID"


# ============================================================
# Recommender utility tests
# ============================================================

def test_parse_skills_basic():
"""parse_skills should split on commas and lowercase each entry."""
Expand Down Expand Up @@ -397,9 +391,7 @@ def test_whitespace_stripped_in_skills():
assert [p["id"] for p in results_clean] == [p["id"] for p in results_spaced]


# ============================================================
# Input validation tests
# ============================================================

def test_validate_all_valid():
"""No errors should be returned when all fields are provided."""
Expand Down Expand Up @@ -434,9 +426,7 @@ def test_validate_all_missing():
assert len(errors) == 4


# ============================================================
# HTTP route tests (using Flask test client)
# ============================================================

def get_client():
"""Return a Flask test client with testing mode enabled."""
Expand Down Expand Up @@ -712,9 +702,7 @@ def test_api_recommend_invalid_level_no_crash():
assert "projects" in data


# ============================================================
# Sitemap and robots.txt tests
# ============================================================

def test_sitemap_returns_200():
"""The /sitemap.xml route must return HTTP 200."""
Expand Down Expand Up @@ -778,9 +766,7 @@ def test_project_links_have_noopener():
assert b'rel="noopener noreferrer"' in response.data


# ============================================================
# Career roadmap comparison tests
# ============================================================

def test_career_roadmaps_load():
"""Career roadmaps JSON must load and contain entries."""
Expand Down Expand Up @@ -864,6 +850,92 @@ def test_sitemap_includes_compare():



# Learning path API tests
#
# _store in utils/learning_path.py is a module-level dict that persists
# for the lifetime of the test process — there's no fixture that clears
# it between tests (unlike clear_cache() for projects). Each test below
# uses its own unique path_id to avoid colliding with paths created by
# other tests or previous runs.

def test_create_learning_path_succeeds_within_size_limit():
"""A reasonably small payload should be created successfully."""
client = get_client()
response = client.post(
"/api/learning-path/test-create-within-limit",
json={"notes": "a small, well within the 64KB limit"},
headers={"X-Learning-Path-Token": "test-token-within-limit"},
)
assert response.status_code == 201, f"Expected 201, got {response.status_code}: {response.get_json()}"
data = response.get_json()
assert data["path_id"] == "test-create-within-limit"


def test_create_learning_path_oversized_payload_rejected():
"""POST /api/learning-path/<id> must return 400 when the raw body exceeds 64KB."""
client = get_client()
oversized_payload = {"notes": "x" * (70 * 1024)} # ~70KB, over the 64KB limit

response = client.post(
"/api/learning-path/test-create-oversized",
json=oversized_payload,
headers={"X-Learning-Path-Token": "test-token-create-oversized"},
)
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.get_json()}"
data = response.get_json()
assert "error" in data
assert "size limit" in data["error"].lower()


def test_update_learning_path_oversized_payload_rejected():
"""PUT /api/learning-path/<id> must return 400 when the raw body exceeds 64KB."""
client = get_client()
path_id = "test-update-oversized"
token = "test-token-update-oversized"

# Create a small, valid path first so there's something to update.
create_response = client.post(
f"/api/learning-path/{path_id}",
json={"notes": "initial small payload"},
headers={"X-Learning-Path-Token": token},
)
assert create_response.status_code == 201

oversized_payload = {"notes": "x" * (70 * 1024)}
response = client.put(
f"/api/learning-path/{path_id}",
json=oversized_payload,
headers={"X-Learning-Path-Token": token},
)
assert response.status_code == 400, f"Expected 400, got {response.status_code}: {response.get_json()}"
data = response.get_json()
assert "error" in data
assert "size limit" in data["error"].lower()


def test_update_learning_path_within_limit_succeeds():
"""A reasonably small update payload should succeed normally, unaffected by the size guard."""
client = get_client()
path_id = "test-update-within-limit"
token = "test-token-update-within-limit"

create_response = client.post(
f"/api/learning-path/{path_id}",
json={"notes": "initial"},
headers={"X-Learning-Path-Token": token},
)
assert create_response.status_code == 201

response = client.put(
f"/api/learning-path/{path_id}",
json={"notes": "updated, still small"},
headers={"X-Learning-Path-Token": token},
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.get_json()}"
data = response.get_json()
assert data["path_id"] == path_id


# ============================================================
# Run tests directly (no pytest required)
# ============================================================
Expand Down Expand Up @@ -904,4 +976,4 @@ def test_ml_recommendation_prefers_relevant_python_data_project():
results = get_recommendations("Python, pandas", "Intermediate", "Data", "High")
recs = results.get("recommendations", [])
titles = [project["title"] for project in recs]
assert any("Data" in title or "Pipeline" in title for title in titles)
assert any("Data" in title or "Pipeline" in title for title in titles)
Loading