diff --git a/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py new file mode 100644 index 0000000..d032ebc --- /dev/null +++ b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.8 on 2026-01-19 02:11 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0011_remove_course_difficulty_score_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="vote", + index=models.Index( + fields=["course", "category", "value"], + name="web_vote_course__b117a9_idx", + ), + ), + ] diff --git a/apps/web/models/course.py b/apps/web/models/course.py index dd2e8a4..da03f31 100644 --- a/apps/web/models/course.py +++ b/apps/web/models/course.py @@ -41,7 +41,7 @@ def search(self, query): elif len(department_or_query) not in self.DEPARTMENT_LENGTHS: # must be query, too long to be department. ignore numbers we may # have. e.g. "Introduction" - return Course.objects.filter(title__icontains=department_or_query) + return Course.objects.filter(course_title__icontains=department_or_query) # elif number and subnumber: # # course with number and subnumber # # e.g. COSC 089.01 @@ -140,7 +140,7 @@ class Meta: ] def __unicode__(self): - return "{}: {}".format(self.short_name(), self.title) + return "{}: {}".format(self.short_name(), self.course_title) def get_absolute_url(self): return reverse("course_detail", args=[self.id]) diff --git a/apps/web/tests/conftest.py b/apps/web/tests/conftest.py new file mode 100644 index 0000000..f5f1998 --- /dev/null +++ b/apps/web/tests/conftest.py @@ -0,0 +1,134 @@ +import pytest +from django.conf import settings +from django.urls import reverse +from rest_framework.test import APIClient +from apps.web.tests import factories + +# ------------------------------------------------------------------------- +# 1. Clients & Authentication +# ------------------------------------------------------------------------- + + +@pytest.fixture +def base_client(): + """Returns an unauthenticated API client.""" + return APIClient() + + +@pytest.fixture +def user(db): + """Returns a saved user instance.""" + return factories.UserFactory() + + +@pytest.fixture +def auth_client(user, base_client): + """Returns an API client authenticated as the 'user' fixture.""" + base_client.force_authenticate(user=user) + return base_client + + +# ------------------------------------------------------------------------- +# 2. Data Fixtures (Models) +# ------------------------------------------------------------------------- + + +@pytest.fixture +def course(db): + """Returns a saved course instance.""" + return factories.CourseFactory() + + +@pytest.fixture +def course_batch(db): + """Returns a batch of 3 general courses.""" + return factories.CourseFactory.create_batch(3) + + +@pytest.fixture +def department_mixed_courses(db): + """Returns a mixed set of courses for filtering/sorting tests.""" + # Note: Using 'title' to match your original course.py + return [ + factories.CourseFactory( + department="MATH", + course_title="Honors Calculus II", + course_code="MATH1560J", + ), + factories.CourseFactory( + department="MATH", course_title="Calculus II", course_code="MATH1160J" + ), + factories.CourseFactory( + department="CHEM", course_title="Chemistry", course_code="CHEM2100J" + ), + ] + + +@pytest.fixture +def review(db, course, user, min_len): + """Returns a saved review instance belonging to 'user'.""" + return factories.ReviewFactory(course=course, user=user, comments="a" * min_len) + + +@pytest.fixture +def other_review(db): + """Returns a review belonging to a different user for security testing.""" + from apps.web.tests.factories import UserFactory, ReviewFactory + + return ReviewFactory(user=UserFactory()) + + +@pytest.fixture +def course_factory(db): + """Access the factory class directly for custom batch creation.""" + return factories.CourseFactory + + +# ------------------------------------------------------------------------- +# 3. Validation & Payloads +# ------------------------------------------------------------------------- + + +@pytest.fixture +def min_len(): + """Retrieves the minimum comment length from project settings.""" + return settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"] + + +@pytest.fixture +def valid_review_data(min_len): + """Generates a valid payload for review creation/update tests.""" + return { + "term": "23F", + "professor": "Dr. Testing", + "comments": "a" * min_len, + } + + +# ------------------------------------------------------------------------- +# 4. URL Fixtures (Routing) +# ------------------------------------------------------------------------- + + +@pytest.fixture +def course_reviews_url(course): + """URL for listing/posting reviews for a specific course.""" + return reverse("course_review_api", kwargs={"course_id": course.id}) + + +@pytest.fixture +def personal_reviews_list_url(): + """URL for the current user's personal review list.""" + return reverse("user_reviews_api") + + +@pytest.fixture +def personal_review_detail_url(review): + """URL for GET/PUT/DELETE a specific review owned by the user.""" + return reverse("user_review_api", kwargs={"review_id": review.id}) + + +@pytest.fixture +def other_review_detail_url(other_review): + """URL for a review NOT owned by the current user (used for 404/Security).""" + return reverse("user_review_api", kwargs={"review_id": other_review.id}) diff --git a/apps/web/tests/factories.py b/apps/web/tests/factories.py index f501948..53c32ee 100644 --- a/apps/web/tests/factories.py +++ b/apps/web/tests/factories.py @@ -1,88 +1,79 @@ import factory +import factory.fuzzy from django.contrib.auth.models import User -from apps.web import models -from lib import constants +# Import models from their individual files +from apps.web.models.course import Course +from apps.web.models.review import Review +from apps.web.models.student import Student +from apps.web.models.course_offering import CourseOffering class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User - username = factory.Faker("first_name") - email = factory.Faker("email") - first_name = factory.Faker("first_name") - last_name = factory.Faker("last_name") - is_active = True + username = factory.Sequence(lambda n: f"user_{n}") + email = factory.LazyAttribute(lambda o: f"{o.username}@example.com") @classmethod - def _prepare(cls, create, **kwargs): - # thanks: https://gist.github.com/mbrochh/2433411 - password = factory.Faker("password") - if "password" in kwargs: - password = kwargs.pop("password") - user = super(UserFactory, cls)._prepare(create, **kwargs) - user.set_password(password) - if create: - user.save() - return user + def _create(cls, model_class, *args, **kwargs): + """Ensure password is hashed correctly so auth_client can log in""" + password = kwargs.pop("password", "password123") + obj = model_class(*args, **kwargs) + obj.set_password(password) + obj.save() + return obj class CourseFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Course + model = Course - title = factory.Faker("words") - department = "COSC" - number = factory.Faker("random_number") - url = factory.Faker("url") - description = factory.Faker("text") + course_title = factory.Faker("sentence", nb_words=3) + department = factory.fuzzy.FuzzyChoice(["MATH", "PHYS", "EECS"]) + number = factory.Sequence(lambda n: 100 + n) + + @factory.lazy_attribute + def course_code(self): + """Generates unique MATH100, PHYS101, etc.""" + return f"{self.department}{str(self.number):<04}J" + + description = factory.Faker("paragraph") class CourseOfferingFactory(factory.django.DjangoModelFactory): class Meta: - model = models.CourseOffering + model = CourseOffering course = factory.SubFactory(CourseFactory) - - term = constants.CURRENT_TERM - section = factory.Faker("random_number") + term = "23F" + section = factory.Sequence(lambda n: n) period = "2A" class ReviewFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Review + model = Review course = factory.SubFactory(CourseFactory) user = factory.SubFactory(UserFactory) + term = "23F" professor = factory.Faker("name") - term = constants.CURRENT_TERM comments = factory.Faker("paragraph") -class DistributiveRequirementFactory(factory.django.DjangoModelFactory): - class Meta: - model = models.DistributiveRequirement - - name = "ART" - distributive_type = models.DistributiveRequirement.DISTRIBUTIVE - - class StudentFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Student + model = Student user = factory.SubFactory(UserFactory) - confirmation_link = User.objects.make_random_password(length=16) -class VoteFactory(factory.django.DjangoModelFactory): +class DistributiveRequirementFactory(factory.django.DjangoModelFactory): class Meta: - model = models.Vote + # Using string reference for potential distributive requirements model + model = "web.DistributiveRequirement" - value = 0 - course = factory.SubFactory(CourseFactory) - user = factory.SubFactory(UserFactory) - category = models.Vote.CATEGORIES.QUALITY + name = factory.Sequence(lambda n: f"Dist{n}") diff --git a/apps/web/tests/lib_tests/test_terms.py b/apps/web/tests/lib_tests/test_terms.py index 662deee..f1dfd65 100644 --- a/apps/web/tests/lib_tests/test_terms.py +++ b/apps/web/tests/lib_tests/test_terms.py @@ -33,6 +33,7 @@ def test_term_regex_allows_for_lower_and_upper_terms(self): term_data and term_data.group("year") == "16" and term_data.group("term") == "w" + and term_data.group("term") == "F" ) def test_term_regex_allows_for_current_term(self): @@ -52,7 +53,7 @@ def test_numeric_value_of_term_returns_0_if_bad_term(self): self.assertEqual(terms.numeric_value_of_term("fall"), 0) def test_numeric_value_of_term_ranks_terms_in_correct_order(self): - correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15W", "16S", "20x"] + correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15w", "16S", "20x"] shuffled_data = list(correct_order) while correct_order == shuffled_data: random.shuffle(shuffled_data) diff --git a/apps/web/tests/test_auth.py b/apps/web/tests/test_auth.py new file mode 100644 index 0000000..14d7e87 --- /dev/null +++ b/apps/web/tests/test_auth.py @@ -0,0 +1,55 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +class TestAuthentication: + """Tests for user authentication and status endpoints""" + + def test_user_status_anonymous(self, base_client): + """Test that unauthenticated users get isAuthenticated=False""" + url = reverse("user_status") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["isAuthenticated"] is False + assert "username" not in response.data + + def test_user_status_authenticated(self, auth_client, user): + """Test that authenticated users get isAuthenticated=True and their username""" + url = reverse("user_status") + # auth_client is already logged in via the fixture in conftest.py + response = auth_client.get(url) + + assert response.status_code == 200 + assert response.data["isAuthenticated"] is True + assert response.data["username"] == user.username + + def test_landing_page_review_count(self, base_client, review): + """Verify landing page shows correct review statistics.""" + url = reverse("landing_api") + response = base_client.get(url) + assert response.status_code == 200 + # Should be at least 1 due to the 'review' fixture + assert response.data["review_count"] == 1 + + def test_landing_page_review_count_empty(self, base_client, db): + """Verify review count is 0 when no reviews exist in the database.""" + url = reverse("landing_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["review_count"] == 0 + + def test_landing_page_review_count_multiple(self, base_client, db): + """Verify review count returns the correct total when multiple reviews exist.""" + from apps.web.tests.factories import ReviewFactory + + # Create 5 reviews across different courses/users + ReviewFactory.create_batch(5) + + url = reverse("landing_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["review_count"] == 5 diff --git a/apps/web/tests/test_course.py b/apps/web/tests/test_course.py new file mode 100644 index 0000000..8f6adc5 --- /dev/null +++ b/apps/web/tests/test_course.py @@ -0,0 +1,248 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +class TestCourseManagement: + """ + Tests for course-related endpoints: + - Course listing and filtering + - Course details retrieval + - Department listings + """ + + def test_list_courses_anonymous(self, base_client, course_factory): + """Verify that any user can list courses with pagination.""" + # Create 3 courses using the factory + course_factory.create_batch(3) + + url = reverse("courses_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert response.data["count"] == 3 + assert "results" in response.data + + def test_filter_courses_by_department(self, base_client, course_factory): + """Verify filtering courses by department code.""" + # Create specific courses + course_factory(department="MATH", course_code="MATH101J") + course_factory(department="PHYS", course_code="PHYS101J") + + url = reverse("courses_api") + # Test filtering for MATH department + response = base_client.get(url, {"department": "MATH"}) + + assert response.status_code == 200 + # Check that filtering worked (only 1 course returned) + assert response.data["count"] == 1 + + # Check course_code instead of department key + # Since 'department' is not in the response, we verify 'MATH101J' + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_filter_courses_by_code(self, base_client, course_factory): + course_factory(course_code="PHYS101J") + course_factory(course_code="MATH102J") + course_factory(course_code="MATH101J") + + url = reverse("courses_api") + + response = base_client.get(url, {"code": "MATH"}) + assert response.status_code == 200 + assert response.data["count"] == 2 + + response = base_client.get(url, {"code": "101"}) + assert response.data["count"] == 2 + + # Verify that authenticated users can sort by review count. + def test_sort_by_review_count(self, auth_client, user, course_factory): + from apps.web.models import Review + + c_hot = course_factory(course_code="ENGR101J") + course_factory(course_code="ENGR100J") + Review.objects.create( + course=c_hot, user=user, term="23S", professor="Prof X", comments="Great!" + ) + url = reverse("courses_api") + + response = auth_client.get( + url, {"sort_by": "review_count", "sort_order": "desc"} + ) + + results = response.data["results"] + assert results[0]["course_code"] == "ENGR101J" + assert results[1]["course_code"] == "ENGR100J" + + # Verify that authenticated users can sort by quality score. + def test_sort_courses_by_score(self, auth_client, user, course_factory): + from apps.web.models import Vote + + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = auth_client.get( + url, {"sort_by": "quality_score", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == "MATH101J" + + # Verify that sort params are ignored for anonymous users (fallback to default). + def test_sort_courses_by_score_anonymous(self, base_client, user, course_factory): + from apps.web.models import Vote + + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = base_client.get( + url, {"sort_by": "quality_score", "sort_order": "desc"} + ) + assert response.status_code == 200 + assert response.data["results"][0]["course_code"] == "MATH102J" + assert response.data["results"][1]["course_code"] == "MATH101J" + + # Verify that authenticated users can filter by min_quality. + def test_filter_courses_by_quality(self, auth_client, user, course_factory): + from apps.web.models import Vote + + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = auth_client.get(url, {"min_quality": 4}) + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["course_code"] == "MATH101J" + + # Verify that min_quality filter is ignored for anonymous users. + def test_filter_courses_by_score_anonymous(self, base_client, user, course_factory): + from apps.web.models import Vote + + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY + ) + + url = reverse("courses_api") + + response = base_client.get(url, {"min_quality": 4}) + assert response.status_code == 200 + assert response.data["count"] == 2 + + # Verify that authenticated users can filter by min_difficulty. + + def test_filter_courses_by_difficulty(self, auth_client, user, course_factory): + from apps.web.models import Vote + + c1 = course_factory(course_code="MATH101J") + Vote.objects.create( + user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY + ) + + c2 = course_factory(course_code="MATH102J") + Vote.objects.create( + user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY + ) + + url = reverse("courses_api") + + response = auth_client.get(url, {"min_difficulty": 4}) + + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["course_code"] == "MATH101J" + + def test_course_detail_retrieval(self, base_client, course): + """Verify retrieving details for a specific course using its ID.""" + url = reverse("course_detail_api", kwargs={"course_id": course.id}) + response = base_client.get(url) + + assert response.status_code == 200 + # Verify the title matches the fixture-created course + assert response.data["course_title"] == course.course_title + + def test_department_listings(self, base_client, course_factory): + """Verify the endpoint that lists all departments and their course counts.""" + course_factory(department="MATH") + course_factory(department="MATH") + course_factory(department="EECS") + + url = reverse("departments_api") + response = base_client.get(url) + + assert response.status_code == 200 + assert isinstance(response.data, list) + + # Find MATH department in the list + math_dept = next(item for item in response.data if item["code"] == "MATH") + assert math_dept["count"] == 2 + + def test_sort_order_asc_and_desc(self, auth_client, course_factory): + course_factory(course_code="MATH101J") + course_factory(course_code="PHY101J") + + url = reverse("courses_api") + + # case 1: Ascending + res_asc = auth_client.get(url, {"sort_by": "course_code", "sort_order": "asc"}) + assert res_asc.data["results"][0]["course_code"] == "MATH101J" + assert res_asc.data["results"][1]["course_code"] == "PHY101J" + + # case 2: Descending + res_desc = auth_client.get( + url, {"sort_by": "course_code", "sort_order": "desc"} + ) + assert res_desc.data["results"][0]["course_code"] == "PHY101J" + assert res_desc.data["results"][1]["course_code"] == "MATH101J" + + def test_pagination_with_default_settings(self, auth_client, course_factory): + for i in range(11): + course_factory(course_code=f"CODE_{i:02d}") + + url = reverse("courses_api") + + resp_p1 = auth_client.get(url, {"page": 1}) + + assert resp_p1.status_code == 200 + assert len(resp_p1.data["results"]) == 10 + assert resp_p1.data["next"] is not None + assert resp_p1.data["previous"] is None + + resp_p2 = auth_client.get(url, {"page": 2}) + + assert resp_p2.status_code == 200 + assert len(resp_p2.data["results"]) == 1 + assert resp_p2.data["previous"] is not None + assert resp_p2.data["next"] is None diff --git a/apps/web/tests/test_review.py b/apps/web/tests/test_review.py new file mode 100644 index 0000000..6038411 --- /dev/null +++ b/apps/web/tests/test_review.py @@ -0,0 +1,179 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from apps.web.models import Review + + +@pytest.mark.django_db +class TestReviewManagement: + """ + Comprehensive tests for Review Management APIs (17 cases). + Organized by authentication status and operation types. + """ + + # ------------------------------------------------------------------------- + # GROUP 1: Anonymous Access (base_client) + # ------------------------------------------------------------------------- + + def test_get_course_reviews_anonymous(self, base_client, course_reviews_url): + """1. Verify anonymous users cannot list course reviews.""" + response = base_client.get(course_reviews_url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + def test_get_personal_reviews_anonymous( + self, base_client, personal_reviews_list_url + ): + """2. Verify anonymous users cannot access personal review list.""" + response = base_client.get(personal_reviews_list_url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + def test_department_api_empty(self, base_client, db): + """3. Verify department list works when database is empty.""" + url = reverse("departments_api") + response = base_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + # ------------------------------------------------------------------------- + # GROUP 2: Authenticated Operations (auth_client) + # ------------------------------------------------------------------------- + + def test_create_review_success( + self, auth_client, course_reviews_url, course, valid_review_data + ): + """4. Verify successful review creation with valid data.""" + response = auth_client.post( + course_reviews_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert Review.objects.filter(course=course).count() == 1 + + def test_list_personal_reviews( + self, auth_client, personal_reviews_list_url, review + ): + """5. Verify user can list their own reviews.""" + response = auth_client.get(personal_reviews_list_url) + assert response.status_code == status.HTTP_200_OK + assert any(r["id"] == review.id for r in response.data) + + def test_retrieve_review_detail( + self, auth_client, personal_review_detail_url, review + ): + """6. Verify user can retrieve their own review details.""" + response = auth_client.get(personal_review_detail_url) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == review.id + + def test_filter_reviews_by_author_me(self, auth_client, course_reviews_url, review): + """7. Verify 'author=me' filters reviews for a specific course.""" + response = auth_client.get(course_reviews_url, {"author": "me"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + def test_search_reviews_by_professor( + self, auth_client, course_reviews_url, course, min_len + ): + """8. Verify search 'q' works for professor names.""" + from apps.web.tests.factories import ReviewFactory + + ReviewFactory(course=course, professor="UniqueProf", comments="c" * min_len) + response = auth_client.get(course_reviews_url, {"q": "UniqueProf"}) + assert any(r["professor"] == "UniqueProf" for r in response.data) + + def test_update_review_success( + self, auth_client, personal_review_detail_url, review, valid_review_data + ): + """9. Verify successful update of user's own review.""" + valid_review_data["comments"] = "Updated content that is long enough." + response = auth_client.put( + personal_review_detail_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_200_OK + review.refresh_from_db() + assert "Updated content" in review.comments + + def test_delete_review_success( + self, auth_client, personal_review_detail_url, review + ): + """10. Verify successful deletion of user's own review.""" + response = auth_client.delete(personal_review_detail_url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Review.objects.filter(id=review.id).exists() + + def test_delete_review_anonymous_forbidden(self, base_client, review): + """11. Verify that unauthenticated users are forbidden from deleting reviews.""" + url = reverse("user_review_api", kwargs={"review_id": review.id}) + response = base_client.delete(url) + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + assert Review.objects.filter(id=review.id).exists() + + def test_department_api_sorting(self, base_client, db): + """12. Verify departments are sorted by code.""" + from apps.web.tests.factories import CourseFactory + + CourseFactory(department="ENGL", course_code="ENGL1000J") + CourseFactory(department="MATH", course_code="MATH1560J") + response = base_client.get(reverse("departments_api")) + assert response.data[0]["code"] == "ENGL" + + # ------------------------------------------------------------------------- + # GROUP 3: Validation, Security & Edge Cases + # ------------------------------------------------------------------------- + + def test_create_validation_length_error( + self, auth_client, course_reviews_url, valid_review_data, min_len + ): + """13. Verify rejection of comments shorter than min_length.""" + valid_review_data["comments"] = "a" * (min_len - 1) + response = auth_client.post( + course_reviews_url, valid_review_data, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_validation_missing_field( + self, auth_client, personal_review_detail_url + ): + """14. Verify PUT fails if required fields (professor) are missing.""" + response = auth_client.put( + personal_review_detail_url, {"comments": "Valid length..."}, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_duplicate_review_denied(self, auth_client, review, valid_review_data): + """15. Verify user cannot review the same course twice (403).""" + url = reverse("course_review_api", kwargs={"course_id": review.course.id}) + response = auth_client.post(url, valid_review_data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_access_other_user_review_404(self, auth_client, other_review_detail_url): + """16. Security: Verify user cannot access someone else's review ID.""" + response = auth_client.get(other_review_detail_url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_non_existent_id(self, auth_client): + """17. Verify deletion of non-existent review ID returns 404.""" + url = reverse("user_review_api", kwargs={"review_id": 99999}) + response = auth_client.delete(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_post_to_invalid_course_id(self, auth_client, valid_review_data): + """18. Verify posting to non-existent course ID returns 404.""" + url = reverse("course_review_api", kwargs={"course_id": 88888}) + response = auth_client.post(url, valid_review_data, format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_review_response_contains_votes( + self, auth_client, personal_review_detail_url + ): + """19. Verify vote statistics are included in the response.""" + response = auth_client.get(personal_review_detail_url) + assert "kudos_count" in response.data diff --git a/apps/web/tests/test_vote.py b/apps/web/tests/test_vote.py new file mode 100644 index 0000000..4f4f4d7 --- /dev/null +++ b/apps/web/tests/test_vote.py @@ -0,0 +1,111 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +@pytest.mark.django_db +class TestVotingSystem: + """ + Tests for the voting system: + - Course quality/difficulty voting (POST /courses//vote) + - Review kudos/dislike voting (POST /reviews//vote) + - Logic for changing and canceling votes + """ + + # ------------------------------------------------------------------------- + # 1. Course Voting (course_vote_api) + # ------------------------------------------------------------------------- + + def test_course_vote_quality_success(self, auth_client, course): + """Test authenticated user voting for course quality.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + data = {"value": 5, "forLayup": False} + response = auth_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert "new_score" in response.data + assert response.data["new_vote_count"] == 1 + assert response.data["was_unvote"] is False + + def test_course_vote_change_value(self, auth_client, course): + """Verify user can change their vote value (e.g., from 5 to 3).""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + + # Initial vote + auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + # Change vote + response = auth_client.post(url, {"value": 3, "forLayup": False}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["new_score"] == 3.0 + assert response.data["new_vote_count"] == 1 # Count stays same + + def test_course_vote_cancel(self, auth_client, course): + """Verify voting the same value twice cancels (unvotes) the vote.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + + auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + # Vote same value again to toggle off + response = auth_client.post(url, {"value": 5, "forLayup": False}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["was_unvote"] is True + assert response.data["new_vote_count"] == 0 + + def test_course_vote_invalid_range_400(self, auth_client, course): + """Verify 400 error for scores outside 1-5.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + response = auth_client.post( + url, {"value": 10, "forLayup": False}, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_course_vote_anonymous_denied(self, base_client, course): + """Verify unauthenticated users cannot vote.""" + url = reverse("course_vote_api", kwargs={"course_id": course.id}) + response = base_client.post(url, {"value": 5, "forLayup": False}, format="json") + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] + + # ------------------------------------------------------------------------- + # 2. Review Voting (review_vote_api) + # ------------------------------------------------------------------------- + + def test_review_vote_kudos_success(self, auth_client, review): + """Test authenticated user giving kudos to a review.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + data = {"is_kudos": True} + response = auth_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["kudos_count"] == 1 + assert response.data["user_vote"] is True + + def test_review_vote_toggle_off(self, auth_client, review): + """Verify that clicking kudos twice cancels the vote.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + + auth_client.post(url, {"is_kudos": True}, format="json") + # Second click + response = auth_client.post(url, {"is_kudos": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["kudos_count"] == 0 + assert response.data["user_vote"] is None + + def test_review_vote_not_found_404(self, auth_client): + """Verify 404 for non-existent review ID.""" + url = reverse("review_vote_api", kwargs={"review_id": 99999}) + response = auth_client.post(url, {"is_kudos": True}, format="json") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_review_vote_anonymous_denied(self, base_client, review): + """Verify unauthenticated users cannot vote on reviews.""" + url = reverse("review_vote_api", kwargs={"review_id": review.id}) + response = base_client.post(url, {"is_kudos": True}, format="json") + assert response.status_code in [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ] diff --git a/pyproject.toml b/pyproject.toml index 90395bd..7dee0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,16 @@ package = false [dependency-groups] dev = [ + "factory-boy>=3.3.3", "prek>=0.2.24", + "pytest>=9.0.2", + "pytest-django>=4.11.1", ] lint = [ "ruff==0.14.5", ] +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "website.settings" +python_files = ["tests.py", "test_*.py", "*_tests.py"] +addopts = "--reuse-db --strict-markers --ignore=apps/web/tests/lib_tests --ignore=apps/web/tests/model_tests" +testpaths = ["apps"] diff --git a/uv.lock b/uv.lock index 01be51a..500d462 100644 --- a/uv.lock +++ b/uv.lock @@ -119,6 +119,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "course-review" version = "0.0.1" @@ -146,7 +155,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "factory-boy" }, { name = "prek" }, + { name = "pytest" }, + { name = "pytest-django" }, ] lint = [ { name = "ruff" }, @@ -175,7 +187,12 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "prek", specifier = ">=0.2.24" }] +dev = [ + { name = "factory-boy", specifier = ">=3.3.3" }, + { name = "prek", specifier = ">=0.2.24" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-django", specifier = ">=4.11.1" }, +] lint = [{ name = "ruff", specifier = "==0.14.5" }] [[package]] @@ -297,6 +314,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, +] + +[[package]] +name = "faker" +version = "40.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/1d/aa43ef59589ddf3647df918143f1bac9eb004cce1c43124ee3347061797d/faker-40.1.0.tar.gz", hash = "sha256:c402212a981a8a28615fea9120d789e3f6062c0c259a82bfb8dff5d273e539d2", size = 1948784, upload-time = "2025-12-29T18:06:00.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/23/e22da510e1ec1488966330bf76d8ff4bd535cbfc93660eeb7657761a1bb2/faker-40.1.0-py3-none-any.whl", hash = "sha256:a616d35818e2a2387c297de80e2288083bc915e24b7e39d2fb5bc66cce3a929f", size = 1985317, upload-time = "2025-12-29T18:05:58.831Z" }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -360,6 +401,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -384,6 +434,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -393,6 +452,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prek" version = "0.2.24" @@ -474,6 +542,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0"