feat(formbuilder): fix tenancy bugs, add admin + services
Fix multi-tenancy/integrity bugs in the models: Form.slug and Field.key were globally unique (unique=True), so two clubs couldn't reuse a form slug and two forms couldn't reuse a field key — make slug unique per club (constraint already present) and key unique per form. Add a (submission, field) uniqueness constraint on Answer. Register all four models in the admin (Field inline on Form, Answer inline on Submission) and add formbuilder to the admin registration smoke test. Add a service layer: - submit_form(form, member, data): enforces is_active / login_required / open window / max_submissions, validates required + choice fields, and writes a Submission with Answers atomically (FormSubmissionError carries per-field errors). - build_form(form): a live django.forms.Form built from a Form's active fields, mapping each FieldType to the matching form field. - form_report(form): a tabular overview of every submission's answers plus per-value tallies for choice-type fields. Full suite at 100% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
286
formbuilder/tests.py
Normal file
286
formbuilder/tests.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django import forms
|
||||
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):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up")
|
||||
self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1)
|
||||
self.size = Field.objects.create(form=self.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"])
|
||||
self.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 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="tier", label="Tier", 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_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, [])
|
||||
Reference in New Issue
Block a user