diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..ed50fbe Binary files /dev/null and b/.coverage differ diff --git a/requirements.txt b/requirements.txt index 97dc7cd..f350b8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi uvicorn +pytest +httpx +pytest-cov diff --git a/src/app.py b/src/app.py index 4ebb1d9..4303928 100644 --- a/src/app.py +++ b/src/app.py @@ -38,6 +38,45 @@ "schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM", "max_participants": 30, "participants": ["john@mergington.edu", "olivia@mergington.edu"] + }, + # Sports related activities + "Soccer Team": { + "description": "Join the school soccer team and compete in local leagues", + "schedule": "Wednesdays and Fridays, 4:00 PM - 5:30 PM", + "max_participants": 18, + "participants": ["lucas@mergington.edu", "mia@mergington.edu"] + }, + "Basketball Club": { + "description": "Practice basketball skills and play friendly matches", + "schedule": "Tuesdays, 5:00 PM - 6:30 PM", + "max_participants": 15, + "participants": ["liam@mergington.edu", "ava@mergington.edu"] + }, + # Artistic activities + "Art Club": { + "description": "Explore painting, drawing, and other visual arts", + "schedule": "Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["charlotte@mergington.edu", "amelia@mergington.edu"] + }, + "Drama Society": { + "description": "Participate in theater productions and acting workshops", + "schedule": "Mondays, 4:00 PM - 5:30 PM", + "max_participants": 20, + "participants": ["jack@mergington.edu", "ella@mergington.edu"] + }, + # Intellectual activities + "Math Olympiad": { + "description": "Prepare for math competitions and solve challenging problems", + "schedule": "Wednesdays, 3:30 PM - 4:30 PM", + "max_participants": 10, + "participants": ["noah@mergington.edu", "isabella@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Fridays, 2:00 PM - 3:30 PM", + "max_participants": 14, + "participants": ["benjamin@mergington.edu", "grace@mergington.edu"] } } @@ -61,7 +100,34 @@ def signup_for_activity(activity_name: str, email: str): # Get the specific activity activity = activities[activity_name] - + + # Validate activity is not at capacity + if len(activity["participants"]) >= activity["max_participants"]: + raise HTTPException(status_code=400, detail="Activity is at full capacity") + + # Validate student is not already signed up + if email in activity["participants"]: + raise HTTPException(status_code=400, detail="Student already signed up for this activity") + # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} + + +@app.delete("/activities/{activity_name}/unregister") +def unregister_from_activity(activity_name: str, email: str): + """Unregister a student from an activity""" + # Validate activity exists + if activity_name not in activities: + raise HTTPException(status_code=404, detail="Activity not found") + + # Get the specific activity + activity = activities[activity_name] + + # Validate student is registered + if email not in activity["participants"]: + raise HTTPException(status_code=400, detail="Student is not registered for this activity") + + # Remove student + activity["participants"].remove(email) + return {"message": f"Unregistered {email} from {activity_name}"} diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..dda246d 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -5,7 +5,7 @@ document.addEventListener("DOMContentLoaded", () => { const messageDiv = document.getElementById("message"); // Function to fetch activities from API - async function fetchActivities() { + async function fetchActivities(refreshDropdown = true) { try { const response = await fetch("/activities"); const activities = await response.json(); @@ -13,6 +13,11 @@ document.addEventListener("DOMContentLoaded", () => { // Clear loading message activitiesList.innerHTML = ""; + // Only clear and repopulate dropdown on initial load + if (refreshDropdown) { + activitySelect.innerHTML = ''; + } + // Populate activities list Object.entries(activities).forEach(([name, details]) => { const activityCard = document.createElement("div"); @@ -20,20 +25,37 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; + const participantsList = details.participants.length > 0 + ? `
No participants yet
'; + activityCard.innerHTML = `${details.description}
+Description: ${details.description}
Schedule: ${details.schedule}
-Availability: ${spotsLeft} spots left
+Capacity: ${details.participants.length}/${details.max_participants}
+Failed to load activities. Please try again later.
"; @@ -50,9 +72,13 @@ document.addEventListener("DOMContentLoaded", () => { try { const response = await fetch( - `/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`, + `/activities/${encodeURIComponent(activity)}/signup`, { method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `email=${encodeURIComponent(email)}`, } ); @@ -62,6 +88,7 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + fetchActivities(false); // Refresh activities list but keep dropdown intact } else { messageDiv.textContent = result.detail || "An error occurred"; messageDiv.className = "error"; @@ -84,3 +111,45 @@ document.addEventListener("DOMContentLoaded", () => { // Initialize app fetchActivities(); }); + +// Function to unregister a participant from an activity +async function unregisterParticipant(activityName, email) { + const messageDiv = document.getElementById("message"); + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/unregister`, + { + method: "DELETE", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `email=${encodeURIComponent(email)}`, + } + ); + + const result = await response.json(); + + if (response.ok) { + messageDiv.textContent = result.message; + messageDiv.className = "success"; + // Refresh the activities list to show updated participants + await fetchActivities(false); // Don't refresh dropdown, just update the display + } else { + messageDiv.textContent = result.detail || "An error occurred"; + messageDiv.className = "error"; + } + + messageDiv.classList.remove("hidden"); + + // Hide message after 5 seconds + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 5000); + } catch (error) { + messageDiv.textContent = "Failed to unregister participant. Please try again."; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + console.error("Error unregistering participant:", error); + } +} diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..4805c13 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,86 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.participants-section h5 { + margin-bottom: 8px; + color: #1a237e; + font-size: 14px; + font-weight: bold; +} + +.participants-list { + margin: 0; + padding: 0; +} + +.participant-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; + font-size: 14px; + color: #555; +} + +.participant-item:last-child { + border-bottom: none; +} + +.participant-email { + flex-grow: 1; +} + +.delete-btn { + background-color: #ff4444; + color: white; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 8px; +} + +.delete-btn:hover { + background-color: #cc0000; +} + +.participants-list li { + padding: 4px 0; + padding-left: 16px; + position: relative; + font-size: 14px; + color: #555; +} + +.participants-list li:before { + content: "•"; + color: #1a237e; + font-weight: bold; + position: absolute; + left: 0; +} + +.no-participants { + font-style: italic; + color: #888; + font-size: 14px; + margin: 0; +} + .form-group { margin-bottom: 15px; } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2551d75 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,52 @@ +# Tests for Mergington High School Activities API + +This directory contains comprehensive tests for the FastAPI application. + +## Running Tests + +To run all tests: +```bash +pytest tests/ -v +``` + +To run tests with coverage: +```bash +pytest tests/ --cov=src --cov-report=term-missing +``` + +## Test Structure + +- `conftest.py` - Test configuration and fixtures +- `test_api.py` - API endpoint tests + +## Test Coverage + +The tests cover: + +### Root Endpoint +- Redirect functionality to static files + +### Activities Endpoint +- Retrieving all activities +- Data structure validation +- Activity details verification + +### Signup Endpoint +- Successful signups +- Duplicate signup prevention +- Capacity validation +- Non-existent activity handling +- Multiple activity signups + +### Unregister Endpoint +- Successful unregistration +- Non-participant unregistration +- Non-existent activity handling +- Complete signup/unregister workflow + +### Edge Cases +- Activity names with spaces +- Various email formats +- Case sensitivity testing + +All tests achieve 100% code coverage of the FastAPI application. \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d5f1ea9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package for Mergington High School Activities API \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ddaa254 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,85 @@ +""" +Test configuration and fixtures for the Mergington High School Activities API tests. +""" + +import pytest +from fastapi.testclient import TestClient +from src.app import app, activities + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI application.""" + return TestClient(app) + + +@pytest.fixture +def reset_activities(): + """Reset activities to initial state before each test.""" + # Store original activities + original_activities = { + "Chess Club": { + "description": "Learn strategies and compete in chess tournaments", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 12, + "participants": ["michael@mergington.edu", "daniel@mergington.edu"] + }, + "Programming Class": { + "description": "Learn programming fundamentals and build software projects", + "schedule": "Tuesdays and Thursdays, 3:30 PM - 4:30 PM", + "max_participants": 20, + "participants": ["emma@mergington.edu", "sophia@mergington.edu"] + }, + "Gym Class": { + "description": "Physical education and sports activities", + "schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM", + "max_participants": 30, + "participants": ["john@mergington.edu", "olivia@mergington.edu"] + }, + "Soccer Team": { + "description": "Join the school soccer team and compete in local leagues", + "schedule": "Wednesdays and Fridays, 4:00 PM - 5:30 PM", + "max_participants": 18, + "participants": ["lucas@mergington.edu", "mia@mergington.edu"] + }, + "Basketball Club": { + "description": "Practice basketball skills and play friendly matches", + "schedule": "Tuesdays, 5:00 PM - 6:30 PM", + "max_participants": 15, + "participants": ["liam@mergington.edu", "ava@mergington.edu"] + }, + "Art Club": { + "description": "Explore painting, drawing, and other visual arts", + "schedule": "Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["charlotte@mergington.edu", "amelia@mergington.edu"] + }, + "Drama Society": { + "description": "Participate in theater productions and acting workshops", + "schedule": "Mondays, 4:00 PM - 5:30 PM", + "max_participants": 20, + "participants": ["jack@mergington.edu", "ella@mergington.edu"] + }, + "Math Olympiad": { + "description": "Prepare for math competitions and solve challenging problems", + "schedule": "Wednesdays, 3:30 PM - 4:30 PM", + "max_participants": 10, + "participants": ["noah@mergington.edu", "isabella@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Fridays, 2:00 PM - 3:30 PM", + "max_participants": 14, + "participants": ["benjamin@mergington.edu", "grace@mergington.edu"] + } + } + + # Reset activities to original state + activities.clear() + activities.update(original_activities) + + yield + + # Clean up after test + activities.clear() + activities.update(original_activities) \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d3ddce9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,241 @@ +""" +Tests for the Mergington High School Activities API endpoints. +""" + +import pytest +from fastapi.testclient import TestClient + + +class TestRootEndpoint: + """Test the root endpoint.""" + + def test_root_redirects_to_static_index(self, client, reset_activities): + """Test that root endpoint redirects to static index.html.""" + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/static/index.html" + + +class TestActivitiesEndpoint: + """Test the activities endpoints.""" + + def test_get_activities_returns_all_activities(self, client, reset_activities): + """Test that GET /activities returns all activities.""" + response = client.get("/activities") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, dict) + + # Check that all expected activities are present + expected_activities = [ + "Chess Club", "Programming Class", "Gym Class", "Soccer Team", + "Basketball Club", "Art Club", "Drama Society", "Math Olympiad", "Science Club" + ] + + for activity_name in expected_activities: + assert activity_name in data + activity = data[activity_name] + assert "description" in activity + assert "schedule" in activity + assert "max_participants" in activity + assert "participants" in activity + assert isinstance(activity["participants"], list) + assert isinstance(activity["max_participants"], int) + + def test_get_activities_structure(self, client, reset_activities): + """Test the structure of activity data.""" + response = client.get("/activities") + data = response.json() + + # Test Chess Club specifically + chess_club = data["Chess Club"] + assert chess_club["description"] == "Learn strategies and compete in chess tournaments" + assert chess_club["schedule"] == "Fridays, 3:30 PM - 5:00 PM" + assert chess_club["max_participants"] == 12 + assert "michael@mergington.edu" in chess_club["participants"] + assert "daniel@mergington.edu" in chess_club["participants"] + + +class TestSignupEndpoint: + """Test the signup functionality.""" + + def test_signup_for_existing_activity_success(self, client, reset_activities): + """Test successful signup for an existing activity.""" + response = client.post( + "/activities/Chess Club/signup?email=newstudent@mergington.edu" + ) + assert response.status_code == 200 + + data = response.json() + assert data["message"] == "Signed up newstudent@mergington.edu for Chess Club" + + # Verify the participant was added + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "newstudent@mergington.edu" in activities_data["Chess Club"]["participants"] + + def test_signup_for_nonexistent_activity(self, client, reset_activities): + """Test signup for a non-existent activity.""" + response = client.post( + "/activities/Nonexistent Club/signup?email=student@mergington.edu" + ) + assert response.status_code == 404 + + data = response.json() + assert data["detail"] == "Activity not found" + + def test_signup_duplicate_student(self, client, reset_activities): + """Test that a student cannot sign up twice for the same activity.""" + # Try to sign up a student who is already registered + response = client.post( + "/activities/Chess Club/signup?email=michael@mergington.edu" + ) + assert response.status_code == 400 + + data = response.json() + assert data["detail"] == "Student already signed up for this activity" + + def test_signup_at_capacity(self, client, reset_activities): + """Test signup when activity is at full capacity.""" + # Fill up Math Olympiad (max 10 participants, currently has 2) + for i in range(8): # Add 8 more to reach capacity + response = client.post( + f"/activities/Math Olympiad/signup?email=student{i}@mergington.edu" + ) + assert response.status_code == 200 + + # Now try to add one more (should fail) + response = client.post( + "/activities/Math Olympiad/signup?email=overflow@mergington.edu" + ) + assert response.status_code == 400 + + data = response.json() + assert data["detail"] == "Activity is at full capacity" + + def test_signup_multiple_activities(self, client, reset_activities): + """Test that a student can sign up for multiple different activities.""" + email = "multistudent@mergington.edu" + + # Sign up for Chess Club + response1 = client.post( + f"/activities/Chess Club/signup?email={email}" + ) + assert response1.status_code == 200 + + # Sign up for Programming Class + response2 = client.post( + f"/activities/Programming Class/signup?email={email}" + ) + assert response2.status_code == 200 + + # Verify student is in both activities + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email in activities_data["Chess Club"]["participants"] + assert email in activities_data["Programming Class"]["participants"] + + +class TestUnregisterEndpoint: + """Test the unregister functionality.""" + + def test_unregister_existing_participant(self, client, reset_activities): + """Test successful unregistration of an existing participant.""" + response = client.delete( + "/activities/Chess Club/unregister?email=michael@mergington.edu" + ) + assert response.status_code == 200 + + data = response.json() + assert data["message"] == "Unregistered michael@mergington.edu from Chess Club" + + # Verify the participant was removed + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "michael@mergington.edu" not in activities_data["Chess Club"]["participants"] + assert "daniel@mergington.edu" in activities_data["Chess Club"]["participants"] # Other participant should remain + + def test_unregister_from_nonexistent_activity(self, client, reset_activities): + """Test unregistration from a non-existent activity.""" + response = client.delete( + "/activities/Nonexistent Club/unregister?email=student@mergington.edu" + ) + assert response.status_code == 404 + + data = response.json() + assert data["detail"] == "Activity not found" + + def test_unregister_non_participant(self, client, reset_activities): + """Test unregistration of a student who is not registered.""" + response = client.delete( + "/activities/Chess Club/unregister?email=notregistered@mergington.edu" + ) + assert response.status_code == 400 + + data = response.json() + assert data["detail"] == "Student is not registered for this activity" + + def test_signup_and_unregister_workflow(self, client, reset_activities): + """Test the complete workflow of signing up and then unregistering.""" + email = "workflow@mergington.edu" + activity = "Art Club" + + # First, sign up + signup_response = client.post( + f"/activities/{activity}/signup?email={email}" + ) + assert signup_response.status_code == 200 + + # Verify signup worked + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email in activities_data[activity]["participants"] + + # Then, unregister + unregister_response = client.delete( + f"/activities/{activity}/unregister?email={email}" + ) + assert unregister_response.status_code == 200 + + # Verify unregistration worked + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email not in activities_data[activity]["participants"] + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_activity_name_with_spaces_and_special_chars(self, client, reset_activities): + """Test handling of activity names with spaces.""" + # Chess Club has a space in the name + response = client.post( + "/activities/Chess Club/signup?email=test@mergington.edu" + ) + assert response.status_code == 200 + + def test_email_formats(self, client, reset_activities): + """Test various email formats.""" + valid_emails = [ + "test@mergington.edu", + "test.student@mergington.edu", + "test_student@mergington.edu", + "test-student@mergington.edu", + "test123@mergington.edu" + ] + + for i, email in enumerate(valid_emails): + response = client.post( + f"/activities/Programming Class/signup?email={email}" + ) + # Each should succeed as they're all different emails + assert response.status_code == 200, f"Failed for email: {email}" + + def test_case_sensitivity_activity_names(self, client, reset_activities): + """Test that activity names are case sensitive.""" + # This should fail because "chess club" != "Chess Club" + response = client.post( + "/activities/chess club/signup?email=test@mergington.edu" + ) + assert response.status_code == 404 \ No newline at end of file