Skip to content

Commit c83f2b9

Browse files
authored
Merge pull request #40 from Tech-JI/feat/web-view-pytest
build: configure pytest-django and add dev dependencies
2 parents 8c77313 + 41c2c6c commit c83f2b9

15 files changed

Lines changed: 941 additions & 341 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.8 on 2026-01-19 02:11
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("web", "0011_remove_course_difficulty_score_and_more"),
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
]
12+
13+
operations = [
14+
migrations.AddIndex(
15+
model_name="vote",
16+
index=models.Index(
17+
fields=["course", "category", "value"],
18+
name="web_vote_course__b117a9_idx",
19+
),
20+
),
21+
]

apps/web/models/course.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def search(self, query):
4141
elif len(department_or_query) not in self.DEPARTMENT_LENGTHS:
4242
# must be query, too long to be department. ignore numbers we may
4343
# have. e.g. "Introduction"
44-
return Course.objects.filter(title__icontains=department_or_query)
44+
return Course.objects.filter(course_title__icontains=department_or_query)
4545
# elif number and subnumber:
4646
# # course with number and subnumber
4747
# # e.g. COSC 089.01
@@ -140,7 +140,7 @@ class Meta:
140140
]
141141

142142
def __unicode__(self):
143-
return "{}: {}".format(self.short_name(), self.title)
143+
return "{}: {}".format(self.short_name(), self.course_title)
144144

145145
def get_absolute_url(self):
146146
return reverse("course_detail", args=[self.id])

apps/web/tests/conftest.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import pytest
2+
from django.conf import settings
3+
from django.urls import reverse
4+
from rest_framework.test import APIClient
5+
from apps.web.tests import factories
6+
7+
# -------------------------------------------------------------------------
8+
# 1. Clients & Authentication
9+
# -------------------------------------------------------------------------
10+
11+
12+
@pytest.fixture
13+
def base_client():
14+
"""Returns an unauthenticated API client."""
15+
return APIClient()
16+
17+
18+
@pytest.fixture
19+
def user(db):
20+
"""Returns a saved user instance."""
21+
return factories.UserFactory()
22+
23+
24+
@pytest.fixture
25+
def auth_client(user, base_client):
26+
"""Returns an API client authenticated as the 'user' fixture."""
27+
base_client.force_authenticate(user=user)
28+
return base_client
29+
30+
31+
# -------------------------------------------------------------------------
32+
# 2. Data Fixtures (Models)
33+
# -------------------------------------------------------------------------
34+
35+
36+
@pytest.fixture
37+
def course(db):
38+
"""Returns a saved course instance."""
39+
return factories.CourseFactory()
40+
41+
42+
@pytest.fixture
43+
def course_batch(db):
44+
"""Returns a batch of 3 general courses."""
45+
return factories.CourseFactory.create_batch(3)
46+
47+
48+
@pytest.fixture
49+
def department_mixed_courses(db):
50+
"""Returns a mixed set of courses for filtering/sorting tests."""
51+
# Note: Using course_title to match current course model field
52+
return [
53+
factories.CourseFactory(
54+
department="MATH",
55+
course_title="Honors Calculus II",
56+
course_code="MATH1560J",
57+
),
58+
factories.CourseFactory(
59+
department="MATH", course_title="Calculus II", course_code="MATH1160J"
60+
),
61+
factories.CourseFactory(
62+
department="CHEM", course_title="Chemistry", course_code="CHEM2100J"
63+
),
64+
]
65+
66+
67+
@pytest.fixture
68+
def review(db, course, user, min_len):
69+
"""Returns a saved review instance belonging to 'user'."""
70+
return factories.ReviewFactory(course=course, user=user, comments="a" * min_len)
71+
72+
73+
@pytest.fixture
74+
def other_review(db):
75+
"""Returns a review belonging to a different user for security testing."""
76+
from apps.web.tests.factories import UserFactory, ReviewFactory
77+
78+
return ReviewFactory(user=UserFactory())
79+
80+
81+
@pytest.fixture
82+
def course_factory(db):
83+
"""Access the factory class directly for custom batch creation."""
84+
return factories.CourseFactory
85+
86+
87+
# -------------------------------------------------------------------------
88+
# 3. Validation & Payloads
89+
# -------------------------------------------------------------------------
90+
91+
92+
@pytest.fixture
93+
def min_len():
94+
"""Retrieves the minimum comment length from project settings."""
95+
return settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"]
96+
97+
98+
@pytest.fixture
99+
def valid_review_data(min_len):
100+
"""Generates a valid payload for review creation/update tests."""
101+
return {
102+
"term": "23F",
103+
"professor": "Dr. Testing",
104+
"comments": "a" * min_len,
105+
}
106+
107+
108+
# -------------------------------------------------------------------------
109+
# 4. URL Fixtures (Routing)
110+
# -------------------------------------------------------------------------
111+
112+
113+
@pytest.fixture
114+
def course_reviews_url(course):
115+
"""URL for listing/posting reviews for a specific course."""
116+
return reverse("course_review_api", kwargs={"course_id": course.id})
117+
118+
119+
@pytest.fixture
120+
def personal_reviews_list_url():
121+
"""URL for the current user's personal review list."""
122+
return reverse("user_reviews_api")
123+
124+
125+
@pytest.fixture
126+
def personal_review_detail_url(review):
127+
"""URL for GET/PUT/DELETE a specific review owned by the user."""
128+
return reverse("user_review_api", kwargs={"review_id": review.id})
129+
130+
131+
@pytest.fixture
132+
def other_review_detail_url(other_review):
133+
"""URL for a review NOT owned by the current user (used for 404/Security)."""
134+
return reverse("user_review_api", kwargs={"review_id": other_review.id})

apps/web/tests/factories.py

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,79 @@
11
import factory
2+
import factory.fuzzy
23
from django.contrib.auth.models import User
34

4-
from apps.web import models
5-
from lib import constants
5+
# Import models from their individual files
6+
from apps.web.models.course import Course
7+
from apps.web.models.review import Review
8+
from apps.web.models.student import Student
9+
from apps.web.models.course_offering import CourseOffering
610

711

812
class UserFactory(factory.django.DjangoModelFactory):
913
class Meta:
1014
model = User
1115

12-
username = factory.Faker("first_name")
13-
email = factory.Faker("email")
14-
first_name = factory.Faker("first_name")
15-
last_name = factory.Faker("last_name")
16-
is_active = True
16+
username = factory.Sequence(lambda n: f"user_{n}")
17+
email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")
1718

1819
@classmethod
19-
def _prepare(cls, create, **kwargs):
20-
# thanks: https://gist.github.com/mbrochh/2433411
21-
password = factory.Faker("password")
22-
if "password" in kwargs:
23-
password = kwargs.pop("password")
24-
user = super(UserFactory, cls)._prepare(create, **kwargs)
25-
user.set_password(password)
26-
if create:
27-
user.save()
28-
return user
20+
def _create(cls, model_class, *args, **kwargs):
21+
"""Ensure password is hashed correctly so auth_client can log in"""
22+
password = kwargs.pop("password", "password123")
23+
obj = model_class(*args, **kwargs)
24+
obj.set_password(password)
25+
obj.save()
26+
return obj
2927

3028

3129
class CourseFactory(factory.django.DjangoModelFactory):
3230
class Meta:
33-
model = models.Course
31+
model = Course
3432

35-
title = factory.Faker("words")
36-
department = "COSC"
37-
number = factory.Faker("random_number")
38-
url = factory.Faker("url")
39-
description = factory.Faker("text")
33+
course_title = factory.Faker("sentence", nb_words=3)
34+
department = factory.fuzzy.FuzzyChoice(["MATH", "PHYS", "EECS"])
35+
number = factory.Sequence(lambda n: 100 + n)
36+
37+
@factory.lazy_attribute
38+
def course_code(self):
39+
"""Generates unique MATH100, PHYS101, etc."""
40+
return f"{self.department}{str(self.number):<04}J"
41+
42+
description = factory.Faker("paragraph")
4043

4144

4245
class CourseOfferingFactory(factory.django.DjangoModelFactory):
4346
class Meta:
44-
model = models.CourseOffering
47+
model = CourseOffering
4548

4649
course = factory.SubFactory(CourseFactory)
47-
48-
term = constants.CURRENT_TERM
49-
section = factory.Faker("random_number")
50+
term = "23F"
51+
section = factory.Sequence(lambda n: n)
5052
period = "2A"
5153

5254

5355
class ReviewFactory(factory.django.DjangoModelFactory):
5456
class Meta:
55-
model = models.Review
57+
model = Review
5658

5759
course = factory.SubFactory(CourseFactory)
5860
user = factory.SubFactory(UserFactory)
5961

62+
term = "23F"
6063
professor = factory.Faker("name")
61-
term = constants.CURRENT_TERM
6264
comments = factory.Faker("paragraph")
6365

6466

65-
class DistributiveRequirementFactory(factory.django.DjangoModelFactory):
66-
class Meta:
67-
model = models.DistributiveRequirement
68-
69-
name = "ART"
70-
distributive_type = models.DistributiveRequirement.DISTRIBUTIVE
71-
72-
7367
class StudentFactory(factory.django.DjangoModelFactory):
7468
class Meta:
75-
model = models.Student
69+
model = Student
7670

7771
user = factory.SubFactory(UserFactory)
78-
confirmation_link = User.objects.make_random_password(length=16)
7972

8073

81-
class VoteFactory(factory.django.DjangoModelFactory):
74+
class DistributiveRequirementFactory(factory.django.DjangoModelFactory):
8275
class Meta:
83-
model = models.Vote
76+
# Using string reference for potential distributive requirements model
77+
model = "web.DistributiveRequirement"
8478

85-
value = 0
86-
course = factory.SubFactory(CourseFactory)
87-
user = factory.SubFactory(UserFactory)
88-
category = models.Vote.CATEGORIES.QUALITY
79+
name = factory.Sequence(lambda n: f"Dist{n}")

apps/web/tests/lib_tests/test_terms.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ def test_term_regex_allows_for_lower_and_upper_terms(self):
3434
and term_data.group("year") == "16"
3535
and term_data.group("term") == "w"
3636
)
37+
term_data = terms.term_regex.match("16F")
38+
self.assertTrue(
39+
term_data
40+
and term_data.group("year") == "16"
41+
and term_data.group("term") == "F"
42+
)
3743

3844
def test_term_regex_allows_for_current_term(self):
3945
term_data = terms.term_regex.match(constants.CURRENT_TERM)
@@ -52,7 +58,7 @@ def test_numeric_value_of_term_returns_0_if_bad_term(self):
5258
self.assertEqual(terms.numeric_value_of_term("fall"), 0)
5359

5460
def test_numeric_value_of_term_ranks_terms_in_correct_order(self):
55-
correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15W", "16S", "20x"]
61+
correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15w", "16S", "20x"]
5662
shuffled_data = list(correct_order)
5763
while correct_order == shuffled_data:
5864
random.shuffle(shuffled_data)
@@ -66,9 +72,10 @@ def test_numeric_value_of_term_gives_expected_numeric_value(self):
6672
self.assertEqual(terms.numeric_value_of_term("16W"), 161)
6773

6874
def test_is_valid_term_returns_false_if_in_future(self):
69-
next_year = (
70-
int(terms.term_regex.match(constants.CURRENT_TERM).group("year")) + 1
71-
)
75+
term_data = terms.term_regex.match(constants.CURRENT_TERM)
76+
if term_data is None:
77+
raise AssertionError("CURRENT_TERM did not match term_regex")
78+
next_year = int(term_data.group("year")) + 1
7279
self.assertFalse(terms.is_valid_term("{}f".format(next_year)))
7380

7481
def test_is_valid_term_returns_false_if_no_term(self):

apps/web/tests/model_tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)