From 43bf28e3dc8af026d357c83087004e6b43fb0825 Mon Sep 17 00:00:00 2001 From: Sasha Romijn Date: Sun, 11 Jan 2026 18:30:40 +0100 Subject: [PATCH 1/2] Add Django tests, CI, coverage, and make TEST_GROUP_QUESTION_AMOUNT configurable --- .github/workflows/ci.yml | 34 ++++++ penaltyparty/pp/tests/__init__.py | 1 + penaltyparty/pp/tests/factories.py | 62 ++++++++++ penaltyparty/pp/tests/test_models.py | 89 ++++++++++++++ penaltyparty/pp/tests/test_views.py | 167 +++++++++++++++++++++++++++ penaltyparty/pp/views.py | 6 +- 6 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 penaltyparty/pp/tests/__init__.py create mode 100644 penaltyparty/pp/tests/factories.py create mode 100644 penaltyparty/pp/tests/test_models.py create mode 100644 penaltyparty/pp/tests/test_views.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..734f168 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.10, 3.11] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install coverage pytest pytest-django factory_boy + - name: Run tests with coverage + run: | + coverage run --source=penaltyparty -m pytest -q + coverage xml -i + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml diff --git a/penaltyparty/pp/tests/__init__.py b/penaltyparty/pp/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/penaltyparty/pp/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/penaltyparty/pp/tests/factories.py b/penaltyparty/pp/tests/factories.py new file mode 100644 index 0000000..2661002 --- /dev/null +++ b/penaltyparty/pp/tests/factories.py @@ -0,0 +1,62 @@ +import factory +from factory import post_generation +from factory.django import DjangoModelFactory + +from penaltyparty.pp import models + + +class QuestionFactory(DjangoModelFactory): + class Meta: + model = models.Question + + question_text = factory.Sequence(lambda n: f"Question {n}") + status = models.Question.STATUS.active + + +class AnswerFactory(DjangoModelFactory): + class Meta: + model = models.Answer + + question = factory.SubFactory(QuestionFactory) + answer_text = factory.Sequence(lambda n: f"Answer {n}") + is_correct = False + status = models.Answer.STATUS.active + + +class TestGroupFactory(DjangoModelFactory): + class Meta: + model = models.TestGroup + + group_name = factory.Sequence(lambda n: f"Group{n}") + owner_email = factory.LazyAttribute(lambda o: f"{o.group_name.lower()}@example.com") + info_for_test_takers = "" + + @post_generation + def questions(self, create, extracted, **kwargs): + if not create: + return + if extracted: + self.questions.set(extracted) + else: + qs = models.Question.objects.all()[:5] + if qs: + self.questions.set(qs) + + +class TestAttemptFactory(DjangoModelFactory): + class Meta: + model = models.TestAttempt + + participant_name = factory.Sequence(lambda n: f"Participant{n}") + test_group = factory.SubFactory(TestGroupFactory) + + +class TestAttemptAnswerFactory(DjangoModelFactory): + class Meta: + model = models.TestAttemptAnswer + + attempt = factory.SubFactory(TestAttemptFactory) + answer = factory.SubFactory(AnswerFactory) + question_text = factory.SelfAttribute("answer.question.question_text") + answer_text = factory.SelfAttribute("answer.answer_text") + is_correct = factory.SelfAttribute("answer.is_correct") diff --git a/penaltyparty/pp/tests/test_models.py b/penaltyparty/pp/tests/test_models.py new file mode 100644 index 0000000..d09ee61 --- /dev/null +++ b/penaltyparty/pp/tests/test_models.py @@ -0,0 +1,89 @@ +from django.test import TestCase +from django.db import IntegrityError + +from penaltyparty.pp.models import Question, Answer, TestGroup, TestAttempt, TestAttemptAnswer +from .factories import ( + QuestionFactory, + AnswerFactory, + TestGroupFactory, + TestAttemptFactory, + TestAttemptAnswerFactory, +) + + +class QuestionModelTests(TestCase): + def test_answers_random_and_correct(self): + question = QuestionFactory() + answers_list = [AnswerFactory(question=question, is_correct=(i == 0)) for i in range(4)] + + random_answers = question.answers_random() + self.assertCountEqual([a.id for a in random_answers], [a.id for a in answers_list]) + + self.assertEqual(question.correct_answer().id, answers_list[0].id) + + def test_correct_answer_raises_when_no_correct_exists(self): + question = QuestionFactory() + AnswerFactory(question=question, is_correct=False) + AnswerFactory(question=question, is_correct=False) + + with self.assertRaises(Answer.DoesNotExist): + question.correct_answer() + + def test_active_manager_returns_only_active_questions(self): + active_question = QuestionFactory(status=Question.STATUS.active) + QuestionFactory(status=Question.STATUS.inactive) + + qs = Question.active.all() + self.assertIn(active_question, qs) + self.assertEqual(qs.count(), 1) + + +class TestAttemptModelTests(TestCase): + def test_next_question_and_final_score(self): + questions = [QuestionFactory() for _ in range(3)] + test_group = TestGroupFactory(questions=questions) + attempt = TestAttemptFactory(test_group=test_group) + + chosen_question = attempt.next_question() + self.assertIn(chosen_question.id, [q.id for q in questions]) + + for question in questions: + answer = AnswerFactory(question=question, is_correct=(question == questions[0])) + TestAttemptAnswerFactory(attempt=attempt, answer=answer) + + self.assertIsNone(attempt.next_question()) + + attempt.set_final_score() + self.assertEqual(attempt.final_answered, attempt.answers.count()) + self.assertIsNotNone(attempt.final_correct) + + if attempt.final_answered: + percent = attempt.correct_percentage() + self.assertIsInstance(percent, int) + + def test_test_attempt_answer_unique_together_violated_raises_integrity_error(self): + question = QuestionFactory() + answer = AnswerFactory(question=question) + test_group = TestGroupFactory(questions=[question]) + attempt = TestAttemptFactory(test_group=test_group) + + TestAttemptAnswerFactory(attempt=attempt, answer=answer) + + with self.assertRaises(IntegrityError): + TestAttemptAnswer.objects.create( + attempt=attempt, + answer=answer, + question_text=answer.question.question_text, + answer_text=answer.answer_text, + is_correct=answer.is_correct, + ) + + def test_correct_percentage_raises_on_zero_answered(self): + test_group = TestGroupFactory() + attempt = TestAttemptFactory(test_group=test_group) + + attempt.set_final_score() + self.assertEqual(attempt.final_answered, 0) + + with self.assertRaises(ZeroDivisionError): + attempt.correct_percentage() diff --git a/penaltyparty/pp/tests/test_views.py b/penaltyparty/pp/tests/test_views.py new file mode 100644 index 0000000..0105a5c --- /dev/null +++ b/penaltyparty/pp/tests/test_views.py @@ -0,0 +1,167 @@ +from django.test import TestCase, Client, override_settings +from django.urls import reverse +from django.core import mail + +from penaltyparty.pp.models import TestGroup, TestAttempt, TestAttemptAnswer, Question +from .factories import QuestionFactory, AnswerFactory, TestGroupFactory, TestAttemptFactory, TestAttemptAnswerFactory + + +class IndexViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_index_shows_random_question(self): + question = QuestionFactory() + + response = self.client.get(reverse("index")) + self.assertEqual(response.status_code, 200) + self.assertIn("random_question", response.context) + self.assertEqual(response.context["random_question"].id, question.id) + + +@override_settings(TEST_GROUP_QUESTION_AMOUNT=5) +class TestGroupCreateViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_create_view_creates_group_and_sends_email(self): + for _ in range(5): + QuestionFactory() + + data = { + "group_name": "Testers", + "owner_email": "owner@example.com", + "info_for_test_takers": "Info", + } + create_response = self.client.post(reverse("test_group_create"), data) + self.assertEqual(create_response.status_code, 302) + + test_group = TestGroup.objects.get(owner_email="owner@example.com") + self.assertEqual(test_group.questions.count(), 5) + + self.assertEqual(len(mail.outbox), 1) + sent = mail.outbox[0] + self.assertIn("owner@example.com", sent.to) + self.assertTrue(sent.subject) + self.assertIn("Testers", sent.body) + + def test_create_view_fails_when_not_enough_active_questions(self): + for _ in range(2): + QuestionFactory(status=Question.STATUS.active) + + data = { + "group_name": "FewQuestions", + "owner_email": "owner@example.com", + "info_for_test_takers": "", + } + response = self.client.post(reverse("test_group_create"), data) + self.assertEqual(response.status_code, 500) + + def test_create_view_invalid_form_shows_errors(self): + QuestionFactory() + data = { + "group_name": "NoEmail", + "info_for_test_takers": "Missing email", + } + response = self.client.post(reverse("test_group_create"), data) + self.assertEqual(response.status_code, 200) + form = response.context.get("form") + self.assertIsNotNone(form) + self.assertIn("owner_email", form.errors) + + +class TestGroupTakeViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_take_view_creates_attempt_and_redirects(self): + question = QuestionFactory() + test_group = TestGroupFactory(questions=[question]) + + url = reverse("test_group_take", kwargs={"group_token": test_group.token_test_taker}) + take_response = self.client.post(url, {"participant_name": "Alice"}) + self.assertEqual(take_response.status_code, 302) + + test_attempt = TestAttempt.objects.get(test_group=test_group, participant_name="Alice") + self.assertIsNotNone(test_attempt) + + def test_take_view_404_for_invalid_group_token(self): + response = self.client.get(reverse("test_group_take", kwargs={"group_token": "nope"})) + self.assertEqual(response.status_code, 404) + + +class TestAttemptEnterAnswerViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_enter_answer_get_and_post_creates_attempt_answer(self): + question = QuestionFactory() + answer_correct = AnswerFactory(question=question, is_correct=True) + AnswerFactory(question=question, is_correct=False) + test_group = TestGroupFactory(questions=[question]) + attempt = TestAttemptFactory(test_group=test_group) + + url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}) + get_response = self.client.get(url) + self.assertEqual(get_response.status_code, 200) + + post_response = self.client.post(url, {"answer": str(answer_correct.id)}) + self.assertEqual(post_response.status_code, 302) + self.assertTrue(TestAttemptAnswer.objects.filter(attempt=attempt, answer=answer_correct).exists()) + + def test_enter_answer_post_with_invalid_answer_id_returns_404(self): + question = QuestionFactory() + AnswerFactory(question=question, is_correct=True) + test_group = TestGroupFactory(questions=[question]) + attempt = TestAttemptFactory(test_group=test_group) + + url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}) + response = self.client.post(url, {"answer": "999999"}) + self.assertEqual(response.status_code, 404) + + def test_enter_answer_redirects_if_question_already_answered(self): + question = QuestionFactory() + answer = AnswerFactory(question=question, is_correct=True) + test_group = TestGroupFactory(questions=[question]) + attempt = TestAttemptFactory(test_group=test_group) + + TestAttemptAnswerFactory(attempt=attempt, answer=answer) + + url = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + +class TestAttemptNextQuestionViewTests(TestCase): + def setUp(self): + self.client = Client() + + def test_next_question_redirects_to_enter_answer_when_questions_remain(self): + question = QuestionFactory() + test_group = TestGroupFactory(questions=[question]) + attempt = TestAttemptFactory(test_group=test_group) + + url = reverse("test_attempt_next_question", kwargs={"token": attempt.token}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + target = reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}) + self.assertTrue(response["Location"].endswith(target)) + + def test_next_question_marks_done_when_no_questions_remain(self): + questions = [QuestionFactory() for _ in range(2)] + test_group = TestGroupFactory(questions=questions) + attempt = TestAttemptFactory(test_group=test_group) + + for question in questions: + answer = AnswerFactory(question=question, is_correct=True) + self.client.post( + reverse("test_attempt_enter_answer", kwargs={"token": attempt.token, "question": question.pk}), + {"answer": str(answer.id)}, + ) + + done_response = self.client.get(reverse("test_attempt_next_question", kwargs={"token": attempt.token})) + self.assertEqual(done_response.status_code, 302) + + done_url = reverse("test_attempt_done", kwargs={"token": attempt.token}) + self.assertTrue(done_response["Location"].endswith(done_url)) diff --git a/penaltyparty/pp/views.py b/penaltyparty/pp/views.py index 51948da..9d697b8 100644 --- a/penaltyparty/pp/views.py +++ b/penaltyparty/pp/views.py @@ -18,7 +18,7 @@ TestGroup, ) -TEST_GROUP_QUESTION_AMOUNT = 40 +TEST_GROUP_QUESTION_AMOUNT = getattr(settings, "TEST_GROUP_QUESTION_AMOUNT", 40) def index(request): @@ -38,8 +38,9 @@ class TestGroupCreateView(CreateView): template_name = "test_group_create.html" def form_valid(self, form): + question_amount = getattr(settings, "TEST_GROUP_QUESTION_AMOUNT", TEST_GROUP_QUESTION_AMOUNT) pks = Question.active.values_list("pk", flat=True) - random_pk = random.sample(list(pks), TEST_GROUP_QUESTION_AMOUNT) + random_pk = random.sample(list(pks), question_amount) self.object = form.save() self.object.questions.set(Question.objects.filter(pk__in=random_pk)) @@ -147,7 +148,6 @@ def get_context_data(self, **kwargs): def form_valid(self, form): answer = get_object_or_404(Answer, id=form.cleaned_data["answer"]) - # Create a new TestAttemptAnswer record TestAttemptAnswer.objects.update_or_create( attempt=self.test_attempt, answer=answer, From b92776f3a9dfebe41aa1539c27405d8df4f20900 Mon Sep 17 00:00:00 2001 From: Sasha Romijn Date: Sun, 11 Jan 2026 18:33:30 +0100 Subject: [PATCH 2/2] Remove Python 3.10 from CI workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 734f168..0167bdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.10, 3.11] + python-version: [3.11] steps: - uses: actions/checkout@v4 - name: Set up Python