diff --git a/requirements.txt b/requirements.txt index 5d9efb5..f2821b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi uvicorn httpx -watchfiles \ No newline at end of file +watchfiles +pytest \ No newline at end of file diff --git a/src/app.py b/src/app.py index 4ebb1d9..6c3661a 100644 --- a/src/app.py +++ b/src/app.py @@ -38,6 +38,42 @@ "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 competitive soccer team for training and matches", + "schedule": "Mondays, Wednesdays, 4:00 PM - 6:00 PM", + "max_participants": 18, + "participants": ["alex@mergington.edu", "mia@mergington.edu"] + }, + "Swimming Club": { + "description": "Practice swim techniques and compete in friendly meets", + "schedule": "Tuesdays and Thursdays, 5:00 PM - 6:30 PM", + "max_participants": 24, + "participants": ["liam@mergington.edu", "ava@mergington.edu"] + }, + "Art Club": { + "description": "Explore painting, drawing, and creative crafts", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["noah@mergington.edu", "isabella@mergington.edu"] + }, + "Drama Club": { + "description": "Rehearse scenes, and prepare performances for the school", + "schedule": "Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 25, + "participants": ["lucas@mergington.edu", "amelia@mergington.edu"] + }, + "Math Team": { + "description": "Solve challenging math problems and compete in tournaments", + "schedule": "Tuesdays, 3:30 PM - 5:00 PM", + "max_participants": 15, + "participants": ["ethan@mergington.edu", "sophia@mergington.edu"] + }, + "Science Olympiad": { + "description": "Prepare experiments and science challenges for competition", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["oliver@mergington.edu", "charlotte@mergington.edu"] } } @@ -62,6 +98,29 @@ def signup_for_activity(activity_name: str, email: str): # Get the specific activity activity = activities[activity_name] + # Check if student is 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] + + # Check if 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..54d8454 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -19,13 +19,53 @@ document.addEventListener("DOMContentLoaded", () => { activityCard.className = "activity-card"; const spotsLeft = details.max_participants - details.participants.length; + const participantsList = details.participants + .map(participant => ` +
  • + ${participant} + +
  • + `) + .join(''); activityCard.innerHTML = `

    ${name}

    ${details.description}

    Schedule: ${details.schedule}

    Availability: ${spotsLeft} spots left

    +
    + Current Participants (${details.participants.length}/${details.max_participants}): + +
    `; + + // Attach delete event listeners + activityCard.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + const activity = btn.dataset.activity; + const email = btn.dataset.email; + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activity)}/unregister?email=${encodeURIComponent(email)}`, + { method: 'DELETE' } + ); + + if (response.ok) { + fetchActivities(); + } else { + const error = await response.json(); + alert('Error: ' + (error.detail || 'Failed to unregister')); + } + } catch (error) { + alert('Error unregistering participant'); + console.error('Error:', error); + } + }); + }); activitiesList.appendChild(activityCard); @@ -62,6 +102,7 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + fetchActivities(); } else { messageDiv.textContent = result.detail || "An error occurred"; messageDiv.className = "error"; diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..7043dcd 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,59 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #ddd; +} + +.participants-section strong { + display: block; + margin-bottom: 10px; + color: #1a237e; + font-size: 14px; +} + +.participants-list { + list-style: none; + margin-left: 0; + padding-left: 0; + font-size: 14px; + color: #555; +} + +.participants-list li { + margin-bottom: 8px; + word-break: break-all; + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + background-color: #f0f0f0; + border-radius: 4px; +} + +.participant-email { + flex-grow: 1; +} + +.delete-btn { + background-color: #d32f2f; + color: white; + border: none; + padding: 2px 8px; + font-size: 18px; + border-radius: 3px; + cursor: pointer; + margin-left: 8px; + line-height: 1; + transition: background-color 0.2s; +} + +.delete-btn:hover { + background-color: #b71c1c; +} + .form-group { margin-bottom: 15px; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1beb049 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +""" +Pytest configuration and fixtures for the FastAPI tests. +""" + +import pytest +from fastapi.testclient import TestClient +from src import app as app_module +import copy + + +# Original activities state to restore after each test +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 competitive soccer team for training and matches", + "schedule": "Mondays, Wednesdays, 4:00 PM - 6:00 PM", + "max_participants": 18, + "participants": ["alex@mergington.edu", "mia@mergington.edu"] + }, + "Swimming Club": { + "description": "Practice swim techniques and compete in friendly meets", + "schedule": "Tuesdays and Thursdays, 5:00 PM - 6:30 PM", + "max_participants": 24, + "participants": ["liam@mergington.edu", "ava@mergington.edu"] + }, + "Art Club": { + "description": "Explore painting, drawing, and creative crafts", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["noah@mergington.edu", "isabella@mergington.edu"] + }, + "Drama Club": { + "description": "Rehearse scenes, and prepare performances for the school", + "schedule": "Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 25, + "participants": ["lucas@mergington.edu", "amelia@mergington.edu"] + }, + "Math Team": { + "description": "Solve challenging math problems and compete in tournaments", + "schedule": "Tuesdays, 3:30 PM - 5:00 PM", + "max_participants": 15, + "participants": ["ethan@mergington.edu", "sophia@mergington.edu"] + }, + "Science Olympiad": { + "description": "Prepare experiments and science challenges for competition", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["oliver@mergington.edu", "charlotte@mergington.edu"] + } +} + + +@pytest.fixture(autouse=True) +def reset_activities(): + """ + Automatically reset activities to original state before each test. + """ + app_module.activities.clear() + app_module.activities.update(copy.deepcopy(ORIGINAL_ACTIVITIES)) + yield + # Cleanup after test + app_module.activities.clear() + app_module.activities.update(copy.deepcopy(ORIGINAL_ACTIVITIES)) + + +@pytest.fixture +def client(): + """ + Provide a TestClient instance for testing the FastAPI app. + """ + return TestClient(app_module.app) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..8201852 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,296 @@ +""" +Unit tests for the Mergington High School Activities API. +Tests follow the AAA (Arrange-Act-Assert) pattern. +""" + +import pytest +from fastapi.testclient import TestClient + + +class TestGetActivities: + """Tests for GET /activities endpoint""" + + def test_get_activities_returns_200(self, client: TestClient): + """Arrange: Setup already done by fixture + Act: Make GET request to /activities + Assert: Response status is 200""" + # Act + response = client.get("/activities") + + # Assert + assert response.status_code == 200 + + def test_get_activities_returns_dict(self, client: TestClient): + """Arrange: Setup already done by fixture + Act: Make GET request to /activities + Assert: Response contains a dictionary""" + # Act + response = client.get("/activities") + data = response.json() + + # Assert + assert isinstance(data, dict) + + def test_get_activities_contains_expected_fields(self, client: TestClient): + """Arrange: Setup already done by fixture + Act: Make GET request to /activities + Assert: Each activity has required fields""" + # Act + response = client.get("/activities") + data = response.json() + + # Assert + for activity_name, activity_data in data.items(): + assert "description" in activity_data + assert "schedule" in activity_data + assert "max_participants" in activity_data + assert "participants" in activity_data + + def test_get_activities_participants_is_list(self, client: TestClient): + """Arrange: Setup already done by fixture + Act: Make GET request to /activities + Assert: Participants field is a list""" + # Act + response = client.get("/activities") + data = response.json() + + # Assert + for activity_name, activity_data in data.items(): + assert isinstance(activity_data["participants"], list) + + def test_get_activities_contains_chess_club(self, client: TestClient): + """Arrange: Setup already done by fixture + Act: Make GET request to /activities + Assert: Chess Club is in the activities""" + # Act + response = client.get("/activities") + data = response.json() + + # Assert + assert "Chess Club" in data + + +class TestSignupForActivity: + """Tests for POST /activities/{activity_name}/signup endpoint""" + + def test_signup_successful_returns_200(self, client: TestClient): + """Arrange: Prepare email and activity name + Act: POST to signup endpoint + Assert: Response status is 200""" + # Arrange + email = "newstudent@mergington.edu" + activity_name = "Chess Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + + # Assert + assert response.status_code == 200 + + def test_signup_successful_returns_message(self, client: TestClient): + """Arrange: Prepare email and activity name + Act: POST to signup endpoint + Assert: Response contains success message""" + # Arrange + email = "newstudent@mergington.edu" + activity_name = "Chess Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + data = response.json() + + # Assert + assert "message" in data + assert email in data["message"] + assert activity_name in data["message"] + + def test_signup_duplicate_returns_400(self, client: TestClient): + """Arrange: Use an email already in Chess Club + Act: POST to signup endpoint + Assert: Response status is 400""" + # Arrange + email = "michael@mergington.edu" # Already in Chess Club + activity_name = "Chess Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + + # Assert + assert response.status_code == 400 + + def test_signup_duplicate_error_message(self, client: TestClient): + """Arrange: Use an email already in Chess Club + Act: POST to signup endpoint + Assert: Response contains appropriate error message""" + # Arrange + email = "michael@mergington.edu" # Already in Chess Club + activity_name = "Chess Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + data = response.json() + + # Assert + assert "detail" in data + assert "already signed up" in data["detail"] + + def test_signup_nonexistent_activity_returns_404(self, client: TestClient): + """Arrange: Prepare non-existent activity name + Act: POST to signup endpoint + Assert: Response status is 404""" + # Arrange + email = "student@mergington.edu" + activity_name = "NonExistent Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + + # Assert + assert response.status_code == 404 + + def test_signup_nonexistent_activity_error_message(self, client: TestClient): + """Arrange: Prepare non-existent activity name + Act: POST to signup endpoint + Assert: Response contains appropriate error message""" + # Arrange + email = "student@mergington.edu" + activity_name = "NonExistent Club" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": email} + ) + data = response.json() + + # Assert + assert "detail" in data + assert "Activity not found" in data["detail"] + + +class TestUnregisterFromActivity: + """Tests for DELETE /activities/{activity_name}/unregister endpoint""" + + def test_unregister_successful_returns_200(self, client: TestClient): + """Arrange: Use an email already in an activity + Act: DELETE from unregister endpoint + Assert: Response status is 200""" + # Arrange + email = "michael@mergington.edu" # In Chess Club + activity_name = "Chess Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + + # Assert + assert response.status_code == 200 + + def test_unregister_successful_returns_message(self, client: TestClient): + """Arrange: Use an email already in an activity + Act: DELETE from unregister endpoint + Assert: Response contains success message""" + # Arrange + email = "michael@mergington.edu" # In Chess Club + activity_name = "Chess Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + data = response.json() + + # Assert + assert "message" in data + assert email in data["message"] + assert activity_name in data["message"] + + def test_unregister_nonparticipant_returns_400(self, client: TestClient): + """Arrange: Use an email not in the activity + Act: DELETE from unregister endpoint + Assert: Response status is 400""" + # Arrange + email = "notinactivity@mergington.edu" + activity_name = "Chess Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + + # Assert + assert response.status_code == 400 + + def test_unregister_nonparticipant_error_message(self, client: TestClient): + """Arrange: Use an email not in the activity + Act: DELETE from unregister endpoint + Assert: Response contains appropriate error message""" + # Arrange + email = "notinactivity@mergington.edu" + activity_name = "Chess Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + data = response.json() + + # Assert + assert "detail" in data + assert "not registered" in data["detail"] + + def test_unregister_nonexistent_activity_returns_404(self, client: TestClient): + """Arrange: Prepare non-existent activity name + Act: DELETE from unregister endpoint + Assert: Response status is 404""" + # Arrange + email = "student@mergington.edu" + activity_name = "NonExistent Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + + # Assert + assert response.status_code == 404 + + def test_unregister_nonexistent_activity_error_message(self, client: TestClient): + """Arrange: Prepare non-existent activity name + Act: DELETE from unregister endpoint + Assert: Response contains appropriate error message""" + # Arrange + email = "student@mergington.edu" + activity_name = "NonExistent Club" + + # Act + response = client.delete( + f"/activities/{activity_name}/unregister", + params={"email": email} + ) + data = response.json() + + # Assert + assert "detail" in data + assert "Activity not found" in data["detail"]