Nearly all of the wall clock was password hashing: there was no test-time PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on every create_user and every login, hundreds of times over. The fix lives in a DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in sys.argv sniff in settings: a runner is only ever instantiated by `manage.py test`, so there is no env var to mis-set and no import path by which a deployed process can reach the weak hasher. Verified: outside the runner the hasher is still PBKDF2. It also enables the cached template loader (the runner forces DEBUG off *after* settings are read, so Django never turns it on by itself) and silences django.request, whose 4xx/5xx logging buried real test output. Second, the fixtures. Base classes were rebuilding a club, season, admin user, membership, role and MFA authenticator once per test; those are read-only for almost every test, so they move to setUpTestData and are built once per class. Django hands each test its own deep copy and the per-test transaction rolls the rows back, so the handful of tests that mutate them stay isolated -- proved with --shuffle, --reverse and --parallel rather than assumed. Per-test work that genuinely must stay per-test (client sign-ins, waffle cache clears that leak across the transaction boundary) is left in setUp with a comment saying why. Five tests removed, each strictly subsumed by another that asserts a superset; their intent was folded into a comment on the survivor. Regression-pinning tests -- the ones carrying comments naming the exact bug they catch -- were left verbatim throughout. Also closes a real gap this surfaced: teams had a cross-club position test for TeamMembership but not for StaffAssignment, with an unused `other_coach` fixture sitting there waiting for it. Rejected: --parallel by default (every worker re-runs all 88 migrations, buying ~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s, but the schema would then come from models and the suite would stop catching a broken migration). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
16 KiB
Python
372 lines
16 KiB
Python
from datetime import timedelta
|
|
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import IntegrityError
|
|
from django.db.models import ProtectedError
|
|
from django.test import TestCase
|
|
from django.utils import timezone
|
|
|
|
from club.models import Club
|
|
from members.models import Member
|
|
|
|
from .models import Answer, Field, Form, Submission
|
|
from .services import (
|
|
FormSubmissionError,
|
|
build_form,
|
|
build_form_class,
|
|
field_choices,
|
|
form_report,
|
|
submit_form,
|
|
)
|
|
|
|
|
|
class FormbuilderTestBase(TestCase):
|
|
# One two-field form, shared by every test here. The tests that reconfigure it
|
|
# (closing the window, flipping login_required) mutate a per-test copy handed out
|
|
# by setUpTestData, and their saves roll back with the transaction.
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.form = Form.objects.create(club=cls.club, title="Sign-up", slug="sign-up")
|
|
cls.name = Field.objects.create(form=cls.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1)
|
|
cls.size = Field.objects.create(form=cls.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"])
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
|
|
|
|
class ModelTests(FormbuilderTestBase):
|
|
def test_str_methods(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
answer = Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
self.assertEqual(str(self.form), "Sign-up")
|
|
self.assertEqual(str(self.name), "Name")
|
|
self.assertEqual(str(submission), "Sign-up - Jane Doe")
|
|
self.assertEqual(str(answer), "Sign-up - Jane Doe - Name")
|
|
|
|
def test_slug_is_unique_per_club_not_globally(self):
|
|
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
Form.objects.create(club=other_club, title="Sign-up", slug="sign-up")
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Form.objects.create(club=self.club, title="Another", slug="sign-up")
|
|
|
|
def test_key_is_unique_per_form_not_globally(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
Field.objects.create(form=other_form, key="name", label="Name", order=1)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Field.objects.create(form=self.form, key="name", label="Duplicate", order=9)
|
|
|
|
def test_answer_is_unique_per_field_per_submission(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Answer.objects.create(submission=submission, field=self.name, value="Again")
|
|
|
|
def test_deleting_form_cascades_to_fields_and_submissions(self):
|
|
Submission.objects.create(form=self.form, member=self.member)
|
|
|
|
self.form.delete()
|
|
|
|
self.assertFalse(Field.objects.exists())
|
|
self.assertFalse(Submission.objects.exists())
|
|
|
|
def test_field_referenced_by_answer_is_protected(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
Answer.objects.create(submission=submission, field=self.name, value="Jane")
|
|
|
|
with self.assertRaises(ProtectedError):
|
|
self.name.delete()
|
|
|
|
|
|
class FormSlugTests(FormbuilderTestBase):
|
|
def test_slug_auto_populated_from_title(self):
|
|
form = Form.objects.create(club=self.club, title="Registration Form")
|
|
|
|
self.assertEqual(form.slug, "registration-form")
|
|
|
|
def test_explicit_slug_is_preserved(self):
|
|
form = Form.objects.create(club=self.club, title="Registration", slug="reg")
|
|
|
|
self.assertEqual(form.slug, "reg")
|
|
|
|
def test_slug_is_unique_per_club_with_suffix(self):
|
|
first = Form.objects.create(club=self.club, title="Registration")
|
|
second = Form.objects.create(club=self.club, title="Registration")
|
|
|
|
self.assertEqual(first.slug, "registration")
|
|
self.assertEqual(second.slug, "registration-2")
|
|
|
|
|
|
class FieldKeyTests(FormbuilderTestBase):
|
|
def test_key_auto_populated_from_label(self):
|
|
field = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
|
|
self.assertEqual(field.key, "first-name")
|
|
|
|
def test_explicit_key_is_preserved(self):
|
|
field = Field.objects.create(form=self.form, key="fn", label="First Name", order=5)
|
|
|
|
self.assertEqual(field.key, "fn")
|
|
|
|
def test_key_is_unique_per_form_with_suffix(self):
|
|
first = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
second = Field.objects.create(form=self.form, label="First Name", order=6)
|
|
|
|
self.assertEqual(first.key, "first-name")
|
|
self.assertEqual(second.key, "first-name-2")
|
|
|
|
def test_same_key_allowed_in_a_different_form(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
here = Field.objects.create(form=self.form, label="First Name", order=5)
|
|
there = Field.objects.create(form=other_form, label="First Name", order=1)
|
|
|
|
self.assertEqual(here.key, there.key)
|
|
|
|
|
|
class FieldChoicesTests(FormbuilderTestBase):
|
|
def test_string_options(self):
|
|
self.assertEqual(field_choices(self.size), [("S", "S"), ("M", "M"), ("L", "L")])
|
|
|
|
def test_dict_options(self):
|
|
field = Field.objects.create(form=self.form, key="plan", label="Plan", field_type=Field.FieldType.CHOICE, order=3, options=[{"value": "a", "label": "Gold"}, {"value": "b", "label": "Silver"}])
|
|
|
|
self.assertEqual(field_choices(field), [("a", "Gold"), ("b", "Silver")])
|
|
|
|
def test_no_options(self):
|
|
self.assertEqual(field_choices(self.name), [])
|
|
|
|
|
|
class SubmitFormTests(FormbuilderTestBase):
|
|
def test_successful_submission_creates_submission_and_answers(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
|
|
self.assertEqual(submission.form, self.form)
|
|
self.assertEqual(submission.member, self.member)
|
|
self.assertEqual(submission.answers.count(), 2)
|
|
self.assertEqual(submission.answers.get(field=self.name).value, "Jane")
|
|
|
|
def test_optional_field_can_be_omitted(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
self.assertEqual(submission.answers.count(), 1)
|
|
|
|
def test_missing_required_field_is_rejected(self):
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"size": "M"})
|
|
|
|
self.assertIn("name", ctx.exception.errors)
|
|
self.assertFalse(Submission.objects.exists())
|
|
|
|
def test_inactive_form_is_rejected(self):
|
|
self.form.is_active = False
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_login_required_without_member_is_rejected(self):
|
|
self.form.login_required = True
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, None, {"name": "Jane"})
|
|
|
|
def test_anonymous_submission_allowed_when_login_not_required(self):
|
|
submission = submit_form(self.form, None, {"name": "Jane"})
|
|
|
|
self.assertIsNone(submission.member)
|
|
|
|
def test_not_open_yet_is_rejected(self):
|
|
self.form.opens_at = timezone.now() + timedelta(days=1)
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_closed_form_is_rejected(self):
|
|
self.form.closes_at = timezone.now() - timedelta(days=1)
|
|
self.form.save()
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_open_window_respects_when_argument(self):
|
|
self.form.opens_at = timezone.now() + timedelta(days=1)
|
|
self.form.save()
|
|
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"}, when=timezone.now() + timedelta(days=2))
|
|
|
|
self.assertTrue(Submission.objects.filter(pk=submission.pk).exists())
|
|
|
|
def test_max_submissions_per_user_is_enforced(self):
|
|
self.form.max_submissions_per_user = 1
|
|
self.form.save()
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
def test_invalid_choice_is_rejected(self):
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "XL"})
|
|
|
|
self.assertIn("size", ctx.exception.errors)
|
|
|
|
def test_number_field_rejects_non_numeric_input(self):
|
|
# Validation goes through the same dynamic Django Form the UI renders, so a
|
|
# NUMBER field is checked as a decimal — not merely "present".
|
|
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
|
|
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
|
|
|
|
self.assertIn("age", ctx.exception.errors)
|
|
|
|
def test_email_field_rejects_an_invalid_address(self):
|
|
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
|
|
|
|
with self.assertRaises(FormSubmissionError) as ctx:
|
|
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
|
|
|
|
self.assertIn("contact", ctx.exception.errors)
|
|
|
|
def test_multichoice_validation(self):
|
|
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])
|
|
|
|
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "wed"]})
|
|
with self.assertRaises(FormSubmissionError):
|
|
submit_form(self.form, self.member, {"name": "Joe", "days": ["mon", "sun"]})
|
|
self.assertEqual(Answer.objects.filter(field=field).count(), 1)
|
|
|
|
def test_inactive_field_is_ignored(self):
|
|
Field.objects.create(form=self.form, key="secret", label="Secret", field_type=Field.FieldType.TEXT, required=True, is_active=False, order=4)
|
|
|
|
submission = submit_form(self.form, self.member, {"name": "Jane"})
|
|
|
|
self.assertFalse(submission.answers.filter(field__key="secret").exists())
|
|
|
|
def test_unknown_keys_are_ignored(self):
|
|
submission = submit_form(self.form, self.member, {"name": "Jane", "bogus": "x"})
|
|
|
|
self.assertEqual(submission.answers.count(), 1)
|
|
|
|
|
|
class BuildFormTests(FormbuilderTestBase):
|
|
def make_all_field_types_form(self):
|
|
form = Form.objects.create(club=self.club, title="All", slug="all")
|
|
specs = [
|
|
("f_text", Field.FieldType.TEXT, forms.CharField),
|
|
("f_area", Field.FieldType.TEXTAREA, forms.CharField),
|
|
("f_num", Field.FieldType.NUMBER, forms.DecimalField),
|
|
("f_email", Field.FieldType.EMAIL, forms.EmailField),
|
|
("f_date", Field.FieldType.DATE, forms.DateField),
|
|
("f_choice", Field.FieldType.CHOICE, forms.ChoiceField),
|
|
("f_multi", Field.FieldType.MULTICHOICE, forms.MultipleChoiceField),
|
|
("f_check", Field.FieldType.CHECKBOX, forms.BooleanField),
|
|
("f_file", Field.FieldType.FILE, forms.FileField),
|
|
]
|
|
for i, (key, field_type, _) in enumerate(specs):
|
|
Field.objects.create(form=form, key=key, label=key, field_type=field_type, required=False, order=i, options=["a", "b"])
|
|
return form, specs
|
|
|
|
def test_field_classes_map_from_field_type(self):
|
|
form, specs = self.make_all_field_types_form()
|
|
|
|
instance = build_form_class(form)()
|
|
|
|
for key, _, expected in specs:
|
|
with self.subTest(key=key):
|
|
self.assertIsInstance(instance.fields[key], expected)
|
|
|
|
def test_required_and_labels_propagate(self):
|
|
instance = build_form_class(self.form)()
|
|
|
|
self.assertTrue(instance.fields["name"].required)
|
|
self.assertFalse(instance.fields["size"].required)
|
|
self.assertEqual(instance.fields["name"].label, "Name")
|
|
|
|
def test_choice_field_gets_choices(self):
|
|
instance = build_form(self.form)
|
|
|
|
self.assertEqual(list(instance.fields["size"].choices), [("S", "S"), ("M", "M"), ("L", "L")])
|
|
|
|
def test_bound_form_validates(self):
|
|
bound = build_form(self.form, data={"name": "Jane", "size": "M"})
|
|
|
|
self.assertTrue(bound.is_valid())
|
|
self.assertEqual(bound.cleaned_data["name"], "Jane")
|
|
|
|
def test_bound_form_reports_required_error(self):
|
|
bound = build_form(self.form, data={"size": "M"})
|
|
|
|
self.assertFalse(bound.is_valid())
|
|
self.assertIn("name", bound.errors)
|
|
|
|
def test_inactive_fields_are_excluded(self):
|
|
Field.objects.create(form=self.form, key="hidden", label="Hidden", is_active=False, order=9)
|
|
|
|
instance = build_form(self.form)
|
|
|
|
self.assertNotIn("hidden", instance.fields)
|
|
|
|
|
|
class FormReportTests(FormbuilderTestBase):
|
|
def test_columns_are_fields_in_order(self):
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.columns, [self.name, self.size])
|
|
|
|
def test_rows_carry_answer_values(self):
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.count, 1)
|
|
row = report.rows[0]
|
|
self.assertEqual(row.values[self.name.id], "Jane")
|
|
self.assertEqual(row.values[self.size.id], "M")
|
|
|
|
def test_choice_field_is_summarised(self):
|
|
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
|
|
submit_form(self.form, Member.objects.create(first_name="Joe", last_name="Roe"), {"name": "Joe", "size": "M"})
|
|
submit_form(self.form, Member.objects.create(first_name="Kim", last_name="Ash"), {"name": "Kim", "size": "L"})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.summaries[self.size.id], {"M": 2, "L": 1})
|
|
self.assertNotIn(self.name.id, report.summaries)
|
|
|
|
def test_multichoice_values_are_tallied_per_option(self):
|
|
days = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue"])
|
|
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "tue"]})
|
|
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.summaries[days.id], {"mon": 1, "tue": 1})
|
|
|
|
def test_empty_form_reports_no_rows(self):
|
|
report = form_report(self.form)
|
|
|
|
self.assertEqual(report.count, 0)
|
|
self.assertEqual(report.rows, [])
|
|
|
|
|
|
class AnswerCleanTests(FormbuilderTestBase):
|
|
def test_rejects_field_from_another_form(self):
|
|
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
|
|
other_field = Field.objects.create(form=other_form, key="x", label="X", order=1)
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
answer = Answer(submission=submission, field=other_field, value="v")
|
|
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
answer.full_clean()
|
|
self.assertIn("field", ctx.exception.error_dict)
|
|
|
|
def test_accepts_field_from_the_submissions_form(self):
|
|
submission = Submission.objects.create(form=self.form, member=self.member)
|
|
|
|
Answer(submission=submission, field=self.name, value="v").full_clean()
|