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, "