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"]