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>
469 lines
19 KiB
Python
469 lines
19 KiB
Python
from datetime import timedelta
|
|
from decimal import Decimal
|
|
from unittest.mock import patch
|
|
|
|
from django.contrib.admin.sites import AdminSite
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import IntegrityError
|
|
from django.db.models import ProtectedError
|
|
from django.test import RequestFactory, TestCase
|
|
from django.utils import timezone
|
|
|
|
from authentication.models import User
|
|
from club.models import Club, ClubMembership, Season
|
|
from club.tenancy import reset_current_club, set_current_club
|
|
from members.models import Member
|
|
from teams.models import Position, Team
|
|
|
|
from .admin import ProductAdmin
|
|
from .models import (
|
|
AppliedDiscount,
|
|
Cart,
|
|
CartItem,
|
|
Discount,
|
|
DiscountType,
|
|
Invoice,
|
|
Order,
|
|
OrderLine,
|
|
Payment,
|
|
Product,
|
|
)
|
|
|
|
|
|
class ProductSlugTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
|
|
def test_slug_auto_populated_from_name(self):
|
|
product = Product.objects.create(club=self.club, name="Home Jersey")
|
|
|
|
self.assertEqual(product.slug, "home-jersey")
|
|
|
|
def test_explicit_slug_is_preserved(self):
|
|
product = Product.objects.create(club=self.club, name="Home Jersey", slug="custom")
|
|
|
|
self.assertEqual(product.slug, "custom")
|
|
|
|
def test_slug_is_unique_per_club_with_suffix(self):
|
|
first = Product.objects.create(club=self.club, name="Home Jersey")
|
|
second = Product.objects.create(club=self.club, name="Home Jersey")
|
|
|
|
self.assertEqual(first.slug, "home-jersey")
|
|
self.assertEqual(second.slug, "home-jersey-2")
|
|
|
|
def test_same_slug_allowed_in_a_different_club(self):
|
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
here = Product.objects.create(club=self.club, name="Home Jersey")
|
|
there = Product.objects.create(club=other, name="Home Jersey")
|
|
|
|
self.assertEqual(here.slug, there.slug)
|
|
|
|
def test_unsluggable_name_falls_back(self):
|
|
product = Product.objects.create(club=self.club, name="###")
|
|
|
|
self.assertEqual(product.slug, "item")
|
|
|
|
def test_str_returns_name(self):
|
|
product = Product.objects.create(club=self.club, name="Home Jersey")
|
|
|
|
self.assertEqual(str(product), "Home Jersey")
|
|
|
|
|
|
class OpenCartConstraintTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.user = User.objects.create_user(email="shopper@example.com", password="pw")
|
|
|
|
def test_only_one_open_cart_per_user_per_club(self):
|
|
Cart.objects.create(club=self.club, user=self.user)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Cart.objects.create(club=self.club, user=self.user)
|
|
|
|
def test_open_and_closed_carts_coexist(self):
|
|
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.CHECKED_OUT)
|
|
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.ABANDONED)
|
|
Cart.objects.create(club=self.club, user=self.user)
|
|
|
|
self.assertEqual(self.user.carts.count(), 3)
|
|
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 1)
|
|
|
|
def test_open_cart_allowed_in_each_club(self):
|
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
Cart.objects.create(club=self.club, user=self.user)
|
|
Cart.objects.create(club=other, user=self.user)
|
|
|
|
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 2)
|
|
|
|
def test_str(self):
|
|
cart = Cart.objects.create(club=self.club, user=self.user)
|
|
|
|
self.assertEqual(str(cart), f"{self.user} - open")
|
|
|
|
|
|
class CartItemTests(TestCase):
|
|
def test_str(self):
|
|
club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
user = User.objects.create_user(email="shopper@example.com", password="pw")
|
|
cart = Cart.objects.create(club=club, user=user)
|
|
product = Product.objects.create(club=club, name="Home Jersey")
|
|
item = CartItem.objects.create(cart=cart, product=product, quantity=2, unit_price=Decimal("25.00"))
|
|
|
|
self.assertEqual(str(item), "Home Jersey - 2x")
|
|
|
|
|
|
class OrderNumberTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
cls.year = timezone.now().year
|
|
|
|
def make_order(self, **kwargs):
|
|
kwargs.setdefault("club", self.club)
|
|
kwargs.setdefault("purchaser", self.member)
|
|
kwargs.setdefault("total", Decimal("10.00"))
|
|
return Order.objects.create(**kwargs)
|
|
|
|
def test_number_is_generated(self):
|
|
order = self.make_order()
|
|
|
|
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
|
|
|
def test_number_increments_within_club_and_year(self):
|
|
first = self.make_order()
|
|
second = self.make_order()
|
|
|
|
self.assertEqual(first.number, f"ORD-{self.year}-00001")
|
|
self.assertEqual(second.number, f"ORD-{self.year}-00002")
|
|
|
|
def test_number_is_scoped_per_club(self):
|
|
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
self.make_order()
|
|
|
|
order = self.make_order(club=other)
|
|
|
|
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
|
|
|
def test_explicit_number_is_preserved(self):
|
|
order = self.make_order(number="CUSTOM-1")
|
|
|
|
self.assertEqual(order.number, "CUSTOM-1")
|
|
|
|
def test_resaving_keeps_the_number(self):
|
|
order = self.make_order()
|
|
original = order.number
|
|
|
|
order.status = Order.OrderStatus.PAID
|
|
order.save()
|
|
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.number, original)
|
|
|
|
def test_non_numeric_suffix_is_ignored(self):
|
|
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("5"), number=f"ORD-{self.year}-oops")
|
|
|
|
order = self.make_order()
|
|
|
|
self.assertEqual(order.number, f"ORD-{self.year}-00001")
|
|
|
|
def test_club_filled_from_tenant_context(self):
|
|
token = set_current_club(self.club)
|
|
try:
|
|
order = Order.objects.create(purchaser=self.member, total=Decimal("5"))
|
|
finally:
|
|
reset_current_club(token)
|
|
|
|
self.assertEqual(order.club, self.club)
|
|
|
|
def test_retries_on_collision(self):
|
|
taken = self.make_order().number
|
|
with patch.object(Order, "generate_number", side_effect=[taken, "ORD-2999-00001"]):
|
|
order = Order(club=self.club, purchaser=self.member, total=Decimal("5"))
|
|
order.save()
|
|
|
|
self.assertEqual(order.number, "ORD-2999-00001")
|
|
|
|
def test_gives_up_after_exhausting_retries(self):
|
|
taken = self.make_order().number
|
|
with patch.object(Order, "generate_number", return_value=taken), self.assertRaises(IntegrityError):
|
|
Order(club=self.club, purchaser=self.member, total=Decimal("5")).save()
|
|
|
|
def test_str_is_the_number(self):
|
|
order = self.make_order()
|
|
|
|
self.assertEqual(str(order), order.number)
|
|
|
|
|
|
class ShopEntitiesTestBase(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
cls.product = Product.objects.create(club=cls.club, name="Home Jersey")
|
|
cls.order = Order.objects.create(club=cls.club, purchaser=cls.member, total=Decimal("50.00"))
|
|
cls.year = timezone.now().year
|
|
|
|
|
|
class OrderLineTests(ShopEntitiesTestBase):
|
|
def make_line(self, **kwargs):
|
|
kwargs.setdefault("order", self.order)
|
|
kwargs.setdefault("product", self.product)
|
|
kwargs.setdefault("quantity", 2)
|
|
kwargs.setdefault("unit_price", Decimal("25.00"))
|
|
kwargs.setdefault("line_total", Decimal("50.00"))
|
|
return OrderLine.objects.create(**kwargs)
|
|
|
|
def test_str(self):
|
|
self.assertEqual(str(self.make_line()), "Home Jersey - 2x")
|
|
|
|
def test_deleting_order_cascades_to_lines(self):
|
|
self.make_line()
|
|
self.order.delete()
|
|
self.assertFalse(OrderLine.objects.exists())
|
|
|
|
def test_product_is_protected_while_referenced(self):
|
|
self.make_line()
|
|
with self.assertRaises(ProtectedError):
|
|
self.product.delete()
|
|
|
|
|
|
class DiscountTests(ShopEntitiesTestBase):
|
|
def test_slug_and_str(self):
|
|
discount = Discount.objects.create(club=self.club, name="Sibling discount")
|
|
|
|
self.assertEqual(discount.slug, "sibling-discount")
|
|
self.assertEqual(str(discount), "Sibling discount")
|
|
|
|
def test_slug_is_unique_per_club(self):
|
|
Discount.objects.create(club=self.club, name="Sibling")
|
|
second = Discount.objects.create(club=self.club, name="Sibling")
|
|
|
|
self.assertEqual(second.slug, "sibling-2")
|
|
|
|
|
|
class AppliedDiscountTests(ShopEntitiesTestBase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
super().setUpTestData()
|
|
cls.discount = Discount.objects.create(club=cls.club, name="Sibling")
|
|
|
|
def apply(self, **kwargs):
|
|
kwargs.setdefault("order", self.order)
|
|
kwargs.setdefault("discount", self.discount)
|
|
kwargs.setdefault("discount_amount", Decimal("10.00"))
|
|
return AppliedDiscount.objects.create(**kwargs)
|
|
|
|
def test_str_percentage_shows_percent(self):
|
|
applied = self.apply(discount_type=DiscountType.PERCENTAGE)
|
|
|
|
self.assertEqual(str(applied), "Sibling - 10.00%")
|
|
|
|
def test_str_fixed_amount_has_no_percent(self):
|
|
applied = self.apply(discount_type=DiscountType.FIXED_AMOUNT)
|
|
|
|
self.assertEqual(str(applied), "Sibling - 10.00")
|
|
|
|
def test_deleting_order_cascades(self):
|
|
self.apply()
|
|
self.order.delete()
|
|
self.assertFalse(AppliedDiscount.objects.exists())
|
|
|
|
def test_discount_is_protected_while_referenced(self):
|
|
self.apply()
|
|
with self.assertRaises(ProtectedError):
|
|
self.discount.delete()
|
|
|
|
|
|
class PaymentTests(ShopEntitiesTestBase):
|
|
def test_str(self):
|
|
payment = Payment.objects.create(order=self.order, amount=Decimal("50.00"))
|
|
|
|
self.assertEqual(str(payment), f"{self.order} - pending")
|
|
|
|
def test_deleting_order_cascades(self):
|
|
Payment.objects.create(order=self.order, amount=Decimal("50.00"))
|
|
self.order.delete()
|
|
self.assertFalse(Payment.objects.exists())
|
|
|
|
|
|
class InvoiceTests(ShopEntitiesTestBase):
|
|
def test_number_generated_and_str(self):
|
|
invoice = Invoice.objects.create(club=self.club, order=self.order)
|
|
|
|
self.assertEqual(invoice.number, f"INV-{self.year}-00001")
|
|
self.assertEqual(str(invoice), invoice.number)
|
|
|
|
def test_number_increments_per_club(self):
|
|
second_order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00"))
|
|
Invoice.objects.create(club=self.club, order=self.order)
|
|
second = Invoice.objects.create(club=self.club, order=second_order)
|
|
|
|
self.assertEqual(second.number, f"INV-{self.year}-00002")
|
|
|
|
def test_one_invoice_per_order(self):
|
|
Invoice.objects.create(club=self.club, order=self.order)
|
|
|
|
with self.assertRaises(IntegrityError):
|
|
Invoice.objects.create(club=self.club, order=self.order)
|
|
|
|
def test_deleting_order_cascades_to_invoice(self):
|
|
Invoice.objects.create(club=self.club, order=self.order)
|
|
self.order.delete()
|
|
self.assertFalse(Invoice.objects.exists())
|
|
|
|
|
|
class ClubScopeValidationTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + timedelta(days=300))
|
|
cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + timedelta(days=300))
|
|
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
|
|
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
|
cls.stranger = Member.objects.create(first_name="Stray", last_name="Ger")
|
|
|
|
def make_cart(self, club):
|
|
user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw")
|
|
return Cart.objects.create(club=club, user=user)
|
|
|
|
def make_order(self, club):
|
|
return Order.objects.create(club=club, purchaser=self.member, total=Decimal("10.00"))
|
|
|
|
# --- Product ---
|
|
def test_product_rejects_cross_club_season(self):
|
|
product = Product(club=self.club, name="Jersey", season=self.other_season)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
product.full_clean()
|
|
self.assertIn("season", ctx.exception.error_dict)
|
|
|
|
def test_product_rejects_cross_club_staff_role(self):
|
|
position = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True)
|
|
product = Product(club=self.club, name="Jersey", staff_role=position)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
product.full_clean()
|
|
self.assertIn("staff_role", ctx.exception.error_dict)
|
|
|
|
def test_product_accepts_same_club_season(self):
|
|
Product(club=self.club, name="Jersey", season=self.season).full_clean()
|
|
|
|
# --- CartItem ---
|
|
def test_cartitem_rejects_cross_club_product(self):
|
|
cart = self.make_cart(self.club)
|
|
product = Product.objects.create(club=self.other, name="Jersey")
|
|
item = CartItem(cart=cart, product=product, unit_price=Decimal("5"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
item.full_clean()
|
|
self.assertIn("product", ctx.exception.error_dict)
|
|
|
|
def test_cartitem_rejects_cross_club_team(self):
|
|
cart = self.make_cart(self.club)
|
|
product = Product.objects.create(club=self.club, name="Jersey")
|
|
team = Team.objects.create(club=self.other, name="First", short_name="1")
|
|
item = CartItem(cart=cart, product=product, team=team, unit_price=Decimal("5"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
item.full_clean()
|
|
self.assertIn("team", ctx.exception.error_dict)
|
|
|
|
def test_cartitem_rejects_non_member_beneficiary(self):
|
|
cart = self.make_cart(self.club)
|
|
product = Product.objects.create(club=self.club, name="Jersey")
|
|
item = CartItem(cart=cart, product=product, beneficiary=self.stranger, unit_price=Decimal("5"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
item.full_clean()
|
|
self.assertIn("beneficiary", ctx.exception.error_dict)
|
|
|
|
def test_cartitem_accepts_same_club(self):
|
|
cart = self.make_cart(self.club)
|
|
product = Product.objects.create(club=self.club, name="Jersey")
|
|
CartItem(cart=cart, product=product, beneficiary=self.member, unit_price=Decimal("5")).full_clean()
|
|
|
|
def test_cartitem_clean_without_cart_is_noop(self):
|
|
CartItem().clean()
|
|
|
|
# --- OrderLine ---
|
|
def test_orderline_rejects_cross_club_product(self):
|
|
order = self.make_order(self.club)
|
|
product = Product.objects.create(club=self.other, name="Jersey")
|
|
line = OrderLine(order=order, product=product, unit_price=Decimal("5"), line_total=Decimal("5"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
line.full_clean()
|
|
self.assertIn("product", ctx.exception.error_dict)
|
|
|
|
def test_orderline_clean_without_order_is_noop(self):
|
|
OrderLine().clean()
|
|
|
|
# --- Order ---
|
|
def test_order_rejects_non_member_purchaser(self):
|
|
order = Order(club=self.club, purchaser=self.stranger, total=Decimal("10"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
order.full_clean()
|
|
self.assertIn("purchaser", ctx.exception.error_dict)
|
|
|
|
def test_order_accepts_member_purchaser(self):
|
|
Order(club=self.club, purchaser=self.member, total=Decimal("10")).full_clean()
|
|
|
|
# --- AppliedDiscount ---
|
|
def test_applieddiscount_rejects_cross_club_discount(self):
|
|
order = self.make_order(self.club)
|
|
discount = Discount.objects.create(club=self.other, name="Sibling")
|
|
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"))
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
applied.full_clean()
|
|
self.assertIn("discount", ctx.exception.error_dict)
|
|
|
|
def test_applieddiscount_rejects_non_member_applied_by(self):
|
|
order = self.make_order(self.club)
|
|
discount = Discount.objects.create(club=self.club, name="Sibling")
|
|
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"), applied_by=self.stranger)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
applied.full_clean()
|
|
self.assertIn("applied_by", ctx.exception.error_dict)
|
|
|
|
def test_applieddiscount_is_unique_per_order(self):
|
|
order = self.make_order(self.club)
|
|
discount = Discount.objects.create(club=self.club, name="Sibling")
|
|
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
|
|
with self.assertRaises(IntegrityError):
|
|
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
|
|
|
|
def test_applieddiscount_clean_without_order_is_noop(self):
|
|
AppliedDiscount().clean()
|
|
|
|
# --- Invoice ---
|
|
def test_invoice_rejects_cross_club_order(self):
|
|
order = self.make_order(self.other)
|
|
invoice = Invoice(club=self.club, order=order)
|
|
with self.assertRaises(ValidationError) as ctx:
|
|
invoice.full_clean()
|
|
self.assertIn("order", ctx.exception.error_dict)
|
|
|
|
def test_invoice_accepts_same_club_order(self):
|
|
order = self.make_order(self.club)
|
|
Invoice(club=self.club, order=order).full_clean()
|
|
|
|
|
|
class AdminScopingTests(TestCase):
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
|
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
|
today = timezone.localdate()
|
|
cls.season = Season.objects.create(club=cls.club, start_date=today, end_date=today + timedelta(days=300))
|
|
cls.other_season = Season.objects.create(club=cls.other, start_date=today, end_date=today + timedelta(days=300))
|
|
|
|
def test_fk_dropdown_scoped_to_object_club(self):
|
|
product = Product.objects.create(club=self.club, name="Jersey")
|
|
admin_obj = ProductAdmin(Product, AdminSite())
|
|
request = RequestFactory().get("/")
|
|
request._club_obj = product
|
|
|
|
field = admin_obj.formfield_for_foreignkey(Product._meta.get_field("season"), request)
|
|
|
|
self.assertIn(self.season, field.queryset)
|
|
self.assertNotIn(self.other_season, field.queryset)
|