Files
RosterChief/formbuilder/tests.py
Bernard Siebens ffe8a3d301 Speed up and rationalise the test suite (158s -> 16s)
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>
2026-08-11 13:48:02 +02:00

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()