Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1149dab
build: configure pytest-django and add dev dependencies
SherryShijiarui Jan 3, 2026
8d3ed21
fix: update factory compatibility
SherryShijiarui Jan 3, 2026
a3fc806
fix: increase field lengths and update Django+migrations
SherryShijiarui Jan 3, 2026
54059f0
build: test_auth.py
SherryShijiarui Jan 3, 2026
5459ae8
fix: resolve model naming and factory syntax errors
SherryShijiarui Jan 4, 2026
cbd6269
fix: implement robust data factories for course and student
SherryShijiarui Jan 5, 2026
3ab511e
build: test_auth.py test_course.py test_review.py
SherryShijiarui Jan 5, 2026
bcc4148
fix: test_review.py
SherryShijiarui Jan 5, 2026
6439b8f
fix: add more function to test_auth.py and test_course.py
SherryShijiarui Jan 5, 2026
6a44f11
fix: add more function to test_review.py
SherryShijiarui Jan 5, 2026
706f8c5
fix: add more function to test_vote.py
SherryShijiarui Jan 5, 2026
656cb5d
reset to original database
SherryShijiarui Jan 12, 2026
7a3cf02
reset to original py
SherryShijiarui Jan 12, 2026
2cff119
fix: fix test.review.py
SherryShijiarui Jan 19, 2026
74cb780
fix: push 0012_vote_web_vote_course__b117a9_idx.py
SherryShijiarui Jan 19, 2026
088539c
test: add new vote tests (difficulty, change, cancel) and unauthentic…
liaotian756 Jan 19, 2026
bdf5b2e
add tests(filtering and sorting courses with permission check) TODO: …
liaotian756 Jan 19, 2026
8997bda
fix: fix course_code in factories.py
SherryShijiarui Jan 19, 2026
38da811
fix: make test more precise in test_auth.py
SherryShijiarui Jan 19, 2026
bc0334e
FIX: use the right format of course code
liaotian756 Jan 20, 2026
23d6f05
fix: make test_auth.py more conprehensive with 0 and multiple cases
SherryShijiarui Jan 20, 2026
a314ed8
build: add more fixture to conftest.py
SherryShijiarui Jan 20, 2026
97460ba
build: add more fixture to conftest.py
SherryShijiarui Jan 20, 2026
8d3f870
build: add more fixture to conftest.py and add more test to test_revi…
SherryShijiarui Jan 20, 2026
08bd759
fix: fix course_title in conftest.py and course code in test_review.py
SherryShijiarui Jan 20, 2026
82b7d1f
fix: fix test
SherryShijiarui Jan 20, 2026
5aec605
build: add base_client in test_review.py and test_vote.py
SherryShijiarui Jan 20, 2026
fa1f27d
add tests: sort by review_count filter by min_difficulty sort order(a…
SherryShijiarui Jan 20, 2026
f0ba2fa
Merge branch 'feat/web-view-pytest' of github.com:TechJI-2023/CourseR…
liaotian756 Jan 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
4 changes: 2 additions & 2 deletions apps/web/models/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
134 changes: 134 additions & 0 deletions apps/web/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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})
81 changes: 36 additions & 45 deletions apps/web/tests/factories.py
Original file line number Diff line number Diff line change
@@ -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}")
3 changes: 2 additions & 1 deletion apps/web/tests/lib_tests/test_terms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions apps/web/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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
Loading