from io import StringIO from django.contrib.auth import get_user_model from django.core import mail from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command from django.db import IntegrityError, transaction from django.test import Client, TestCase, override_settings from django.urls import reverse from workshops.forms import NoteUploadForm, SeatsForm from workshops.models import Reservation, Venue, Workshop from workshops.services import SoldOut, cancel, reserve class WorkshopContractTests(TestCase): @classmethod def setUpTestData(cls): call_command("seed_lab", stdout=StringIO()) cls.alex = get_user_model().objects.get(username="alex") cls.jay = get_user_model().objects.get(username="jay") cls.workshop = Workshop.objects.get(slug="lamp-care") def test_public_list_and_draft_visibility(self): response = self.client.get("/") self.assertContains(response, "Lamp care") self.assertNotContains(response, "Wood joints") self.assertEqual(self.client.get("/workshops/wood-joints/").status_code, 404) def test_csrf_rejection_and_acceptance(self): client = Client(enforce_csrf_checks=True) client.force_login(self.alex) client.get(self.workshop.get_absolute_url()) self.assertEqual(client.post(reverse("workshops:book", args=[self.workshop.slug]), {"seats":1}).status_code, 403) self.assertEqual(Reservation.objects.count(), 0) token = client.cookies["csrftoken"].value response = client.post(reverse("workshops:book", args=[self.workshop.slug]), {"seats":1}, HTTP_X_CSRFTOKEN=token) self.assertEqual(response.status_code, 302) self.workshop.refresh_from_db() self.assertEqual(self.workshop.remaining, 6) def test_invalid_input_preserves_state(self): self.client.force_login(self.alex) for seats in [0, 5, "many", ""]: response = self.client.post(reverse("workshops:book", args=[self.workshop.slug]), {"seats":seats}) self.assertEqual(response.status_code, 400) self.workshop.refresh_from_db() self.assertEqual(self.workshop.remaining, 7) self.assertEqual(Reservation.objects.count(), 0) def test_duplicate_request_rolls_back_decrement(self): reserve(workshop_id=self.workshop.pk, user=self.alex, seats=2) with self.assertRaises(IntegrityError): reserve(workshop_id=self.workshop.pk, user=self.alex, seats=1) self.workshop.refresh_from_db() self.assertEqual(self.workshop.remaining, 5) self.assertEqual(Reservation.objects.count(), 1) def test_cannot_oversell_or_use_bad_service_values(self): small = Workshop.objects.get(slug="bike-punctures") reserve(workshop_id=small.pk, user=self.alex, seats=4) with self.assertRaises(SoldOut): reserve(workshop_id=small.pk, user=self.jay, seats=1) for seats in [0, 5, True, 1.5]: with self.assertRaises(ValueError): reserve(workshop_id=self.workshop.pk, user=self.jay, seats=seats) small.refresh_from_db() self.assertEqual(small.remaining, 0) def test_ownership_and_single_restoration(self): record = reserve(workshop_id=self.workshop.pk, user=self.alex, seats=2) self.assertFalse(cancel(reservation_id=record.pk, user=self.jay)) self.assertTrue(cancel(reservation_id=record.pk, user=self.alex)) self.assertFalse(cancel(reservation_id=record.pk, user=self.alex)) self.workshop.refresh_from_db() self.assertEqual(self.workshop.remaining, 7) def test_database_rejects_invalid_remaining(self): with self.assertRaises(IntegrityError): with transaction.atomic(): Workshop.objects.filter(pk=self.workshop.pk).update(remaining=99) def test_user_deletion_cannot_bypass_reservation_service(self): from django.db.models.deletion import ProtectedError reserve(workshop_id=self.workshop.pk, user=self.alex, seats=1) with self.assertRaises(ProtectedError): self.alex.delete() self.assertEqual(Reservation.objects.count(), 1) def test_unsafe_title_is_escaped(self): self.workshop.title = "" self.workshop.save(update_fields=["title"]) response = self.client.get(self.workshop.get_absolute_url()) self.assertContains(response, "<script>") self.assertNotContains(response, "