Files
neuf-www/dnscms/tests/test_events.py
T

575 lines
19 KiB
Python

from datetime import datetime, timedelta
import pytest
from django.core.exceptions import ValidationError
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from events.admin import EventDateColumn, OrganizersColumn
from events.models import (
EventCategory,
EventOccurrence,
EventOrganizer,
EventOrganizerLink,
EventPage,
)
from events.views import EventOrganizerCreationForm
from tests.conftest import (
AssociationPageFactory,
CustomImageFactory,
EventPageFactory,
)
def test_eventpage_clean_unsets_specific_pricing_when_free():
page = EventPage(
title="Free event",
slug="free-event",
free=True,
price_regular="100",
price_student="50",
price_member="25",
)
page.clean()
assert page.price_regular == ""
assert page.price_student == ""
assert page.price_member == ""
def test_eventpage_clean_keeps_specific_pricing_when_not_free():
page = EventPage(
title="Paid event",
slug="paid-event",
free=False,
price_regular="100",
price_student="50",
price_member="25",
)
page.clean()
assert page.price_regular == "100"
assert page.price_student == "50"
assert page.price_member == "25"
def test_eventpage_clean_dedupes_organizers_by_name(event_index):
org_a = EventOrganizer.objects.create(name="DNS", slug="dns-a")
org_b = EventOrganizer.objects.create(name="DNS", slug="dns-b")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org_a)
EventOrganizerLink.objects.create(event=event, organizer=org_b)
event = EventPage.objects.get(pk=event.pk)
assert event.organizer_links.count() == 2
event.clean()
assert event.organizer_links.count() == 1
def test_eventpage_clean_dedupes_three_duplicates_and_keeps_distinct(event_index):
dup_1 = EventOrganizer.objects.create(name="DNS", slug="dns-1")
dup_2 = EventOrganizer.objects.create(name="DNS", slug="dns-2")
dup_3 = EventOrganizer.objects.create(name="DNS", slug="dns-3")
distinct = EventOrganizer.objects.create(name="Studentersamfundet", slug="ss")
event = EventPageFactory(parent=event_index)
for organizer in (dup_1, dup_2, dup_3, distinct):
EventOrganizerLink.objects.create(event=event, organizer=organizer)
event = EventPage.objects.get(pk=event.pk)
assert event.organizer_links.count() == 4
event.clean()
names = sorted(link.organizer.name for link in event.organizer_links.all())
assert names == ["DNS", "Studentersamfundet"]
def test_eventoccurrence_clean_rejects_both_venue_and_venue_custom(event_index, venue):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue=venue,
venue_custom="Frederikkeplassen",
)
with pytest.raises(ValidationError) as exc:
occurrence.clean()
assert "venue_custom" in exc.value.message_dict
def test_eventoccurrence_clean_promotes_matching_custom_text_to_venue(event_index, venue):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=f" {venue.title} ",
)
occurrence.clean()
assert occurrence.venue_id == venue.pk
assert occurrence.venue_custom == ""
def test_event_organizer_creation_form_auto_slugifies_name(db):
form = EventOrganizerCreationForm(data={"name": "Forening for ÆØÅ", "external_url": ""})
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.pk is not None
assert organizer.name == "Forening for ÆØÅ"
assert organizer.slug == "forening-for-aeoa"
def test_event_organizer_creation_form_keeps_explicit_slug(db):
organizer = EventOrganizer(name="Forening", slug="custom-slug")
form = EventOrganizerCreationForm(
data={"name": "Forening", "external_url": ""}, instance=organizer
)
assert form.is_valid(), form.errors
organizer = form.save()
assert organizer.slug == "custom-slug"
def test_eventoccurrence_clean_keeps_custom_text_when_no_venue_matches(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(
event=event,
start=timezone.now(),
venue_custom=" Frederikkeplassen ",
)
occurrence.clean()
assert occurrence.venue is None
assert occurrence.venue_custom == "Frederikkeplassen"
def test_eventoccurrence_clean_requires_venue_or_venue_custom(event_index):
event = EventPageFactory(parent=event_index)
occurrence = EventOccurrence(event=event, start=timezone.now())
with pytest.raises(ValidationError) as exc:
occurrence.clean()
assert "venue" in exc.value.message_dict
def test_eventpage_manager_future_filters_past_and_annotates(event_index):
now = timezone.now()
past = EventPageFactory(parent=event_index, title="Past")
EventOccurrence.objects.create(event=past, start=now - timedelta(days=7), venue_custom="Old")
future = EventPageFactory(parent=event_index, title="Future")
EventOccurrence.objects.create(event=future, start=now + timedelta(days=7), venue_custom="New")
results = list(EventPage.objects.live().future().order_by("next_occurrence"))
assert [p.pk for p in results] == [future.pk]
assert results[0].next_occurrence is not None
def test_future_includes_occurrence_late_today(event_index):
today_start = timezone.localtime(timezone.now()).replace(
hour=0, minute=0, second=0, microsecond=0
)
late_today = today_start + timedelta(hours=23, minutes=59)
event = EventPageFactory(parent=event_index, title="Late today")
EventOccurrence.objects.create(event=event, start=late_today, venue_custom="X")
assert event.pk in EventPage.objects.future().values_list("pk", flat=True)
def test_future_excludes_occurrence_just_before_today(event_index):
today_start = timezone.localtime(timezone.now()).replace(
hour=0, minute=0, second=0, microsecond=0
)
just_before_today = today_start - timedelta(seconds=1)
event = EventPageFactory(parent=event_index, title="Just past")
EventOccurrence.objects.create(event=event, start=just_before_today, venue_custom="X")
assert event.pk not in EventPage.objects.future().values_list("pk", flat=True)
def test_future_next_occurrence_picks_earliest_future_ignoring_past(event_index):
now = timezone.now()
soonest_future = now + timedelta(days=3)
event = EventPageFactory(parent=event_index, title="With history")
EventOccurrence.objects.create(event=event, start=now - timedelta(days=30), venue_custom="X")
EventOccurrence.objects.create(event=event, start=soonest_future, venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=10), venue_custom="X")
annotated = EventPage.objects.future().filter(pk=event.pk).first()
assert annotated is not None
assert abs((annotated.next_occurrence - soonest_future).total_seconds()) < 1
def test_graphql_event_index_future_events_query(event_index, graphql_post):
upcoming = EventPageFactory(parent=event_index, title="Upcoming gig")
EventOccurrence.objects.create(
event=upcoming,
start=timezone.now() + timedelta(days=3),
venue_custom="Storsalen",
)
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents { title }
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]]
assert "Upcoming gig" in titles
def test_future_events_does_not_have_n_plus_one_queries(
event_index, venue, association_index, graphql_post
):
"""Regression test: query count for futureEvents stays bounded as events grow."""
konsert = EventCategory.objects.create(name="Konsert", slug="konsert")
association = AssociationPageFactory(parent=association_index, title="DNS")
org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association)
image = CustomImageFactory(title="Cover")
now = timezone.now()
for i in range(5):
event = EventPageFactory(
parent=event_index,
title=f"Event {i}",
body=[("paragraph", "<p>x</p>")],
featured_image=image,
)
event.categories.add(konsert)
EventOrganizerLink.objects.create(event=event, organizer=org)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=i + 1),
venue=venue,
)
home_query = """
query {
eventIndex {
futureEvents {
id
title
subtitle
body { blockType }
featuredImage { url }
occurrences { start end venueCustom venue { title } }
categories { name slug }
organizers { name slug association { title } }
}
}
}
"""
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post(home_query)
assert response.status_code == 200
assert "errors" not in body, body
assert len(body["data"]["eventIndex"]["futureEvents"]) == 5
# Bump only alongside an intentional resolver change.
max_queries = 6
assert len(ctx) <= max_queries, (
f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. "
f"Captured queries:\n"
+ "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries))
)
def test_future_events_does_not_load_wp_import_fields(event_index, graphql_post):
"""wp_* columns must stay deferred and lazy-load on explicit access."""
event = EventPageFactory(parent=event_index, wp_raw_content="marker")
EventOccurrence.objects.create(
event=event, start=timezone.now() + timedelta(days=1), venue_custom="X"
)
with CaptureQueriesContext(connection) as ctx:
response, body = graphql_post("{ eventIndex { futureEvents { id } } }")
assert response.status_code == 200 and "errors" not in body, body
sql = "\n".join(q["sql"] for q in ctx.captured_queries)
assert "wp_raw_content" not in sql, f"wp_* must be deferred. SQL:\n{sql}"
assert EventPage.objects.get(pk=event.pk).wp_raw_content == "marker"
def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post):
now = timezone.now()
later = EventPageFactory(parent=event_index, title="Later gig")
EventOccurrence.objects.create(event=later, start=now + timedelta(days=10), venue_custom="X")
sooner = EventPageFactory(parent=event_index, title="Sooner gig")
EventOccurrence.objects.create(event=sooner, start=now + timedelta(days=3), venue_custom="X")
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents { title }
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
titles = [e["title"] for e in body["data"]["eventIndex"]["futureEvents"]]
assert titles.index("Sooner gig") < titles.index("Later gig")
def test_event_date_column_no_occurrences(event_index):
event = EventPageFactory(parent=event_index)
column = EventDateColumn("event_date")
assert column.get_value(event) == ""
def test_event_date_column_single_occurrence(event_index):
event = EventPageFactory(parent=event_index)
start = timezone.make_aware(datetime(2025, 7, 22, 19, 30))
EventOccurrence.objects.create(event=event, start=start, venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "2025-07-22 kl 19:30"
def test_event_date_column_multiple_occurrences_shows_count(event_index):
event = EventPageFactory(parent=event_index)
now = timezone.now()
EventOccurrence.objects.create(event=event, start=now, venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=1), venue_custom="X")
EventOccurrence.objects.create(event=event, start=now + timedelta(days=2), venue_custom="X")
column = EventDateColumn("event_date")
assert column.get_value(event) == "3 forekomster"
def test_organizers_column_no_organizers(event_index):
event = EventPageFactory(parent=event_index)
column = OrganizersColumn("organizers")
assert column.get_value(event) == ""
def test_organizers_column_single_organizer_shows_name(event_index):
org = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A"
def test_organizers_column_multiple_organizers_truncates_with_count(event_index):
org_a = EventOrganizer.objects.create(name="Forening A", slug="forening-a")
org_b = EventOrganizer.objects.create(name="Forening B", slug="forening-b")
org_c = EventOrganizer.objects.create(name="Forening C", slug="forening-c")
event = EventPageFactory(parent=event_index)
EventOrganizerLink.objects.create(event=event, organizer=org_a, sort_order=0)
EventOrganizerLink.objects.create(event=event, organizer=org_b, sort_order=1)
EventOrganizerLink.objects.create(event=event, organizer=org_c, sort_order=2)
column = OrganizersColumn("organizers")
assert column.get_value(event) == "Forening A (+2)"
@pytest.fixture
def comprehensive_event(event_index, venue, association_index):
"""A fully-populated paid EventPage exercising every field exposed via GraphQL."""
image = CustomImageFactory(
title="Cover",
alt="Et fotografi av en gris med solbriller",
attribution="Foto: Test",
)
konsert = EventCategory.objects.create(
name="Konsert", slug="konsert", show_in_filters=True, pig="pigHeadLogo"
)
klubb = EventCategory.objects.create(name="Klubb", slug="klubb")
association = AssociationPageFactory(
parent=association_index,
title="Internal",
association_type="forening",
)
internal_org = EventOrganizer.objects.create(
name="Internal", slug="internal", association=association
)
external_org = EventOrganizer.objects.create(
name="External",
slug="external",
external_url="https://external.example.com",
)
event = EventPageFactory(
parent=event_index,
title="Et arrangement",
slug="et-arrangement",
subtitle="En undertekst",
lead="<p>Ingress.</p>",
body=[("paragraph", "<p>Body content.</p>")],
pig="automatic",
free=False,
price_regular="150",
price_student="100",
price_member="75",
ticket_url="https://example.com/tickets",
facebook_url="https://facebook.com/example",
featured_image=image,
)
event.categories.add(konsert, klubb)
EventOrganizerLink.objects.create(event=event, organizer=internal_org)
EventOrganizerLink.objects.create(event=event, organizer=external_org)
now = timezone.now()
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=5),
end=now + timedelta(days=5, hours=3),
venue=venue,
)
EventOccurrence.objects.create(
event=event,
start=now + timedelta(days=12),
end=now + timedelta(days=12, hours=2),
venue_custom="Frederikkeplassen",
)
event.save()
return event
def test_graphql_event_index_returns_all_fields_for_comprehensive_event(
comprehensive_event, graphql_post
):
response, body = graphql_post(
"""
query {
eventIndex {
futureEvents {
title
slug
subtitle
lead
body {
blockType
field
... on RichTextBlock {
value
}
}
pig
free
priceRegular
priceStudent
priceMember
ticketUrl
facebookUrl
featuredImage {
alt
attribution
}
categories {
name
slug
showInFilters
pig
}
organizers {
name
slug
externalUrl
association {
title
}
}
occurrences {
start
end
venueCustom
venue {
title
}
}
}
}
}
"""
)
assert response.status_code == 200
assert "errors" not in body, body
events = body["data"]["eventIndex"]["futureEvents"]
event = next(e for e in events if e["title"] == "Et arrangement")
assert event["slug"] == "et-arrangement"
assert event["subtitle"] == "En undertekst"
assert "Ingress." in event["lead"]
assert event["pig"] == "automatic"
assert event["free"] is False
assert event["priceRegular"] == "150"
assert event["priceStudent"] == "100"
assert event["priceMember"] == "75"
assert event["ticketUrl"] == "https://example.com/tickets"
assert event["facebookUrl"] == "https://facebook.com/example"
assert event["featuredImage"]["alt"] == "Et fotografi av en gris med solbriller"
assert event["featuredImage"]["attribution"] == "Foto: Test"
assert event["body"][0]["blockType"] == "RichTextBlock"
assert "Body content." in event["body"][0]["value"]
categories_by_name = {c["name"]: c for c in event["categories"]}
assert set(categories_by_name) == {"Konsert", "Klubb"}
assert categories_by_name["Konsert"]["slug"] == "konsert"
assert categories_by_name["Konsert"]["showInFilters"] is True
assert categories_by_name["Konsert"]["pig"] == "pigHeadLogo"
assert categories_by_name["Klubb"]["showInFilters"] is False
organizers_by_name = {o["name"]: o for o in event["organizers"]}
assert set(organizers_by_name) == {"Internal", "External"}
assert organizers_by_name["Internal"]["association"]["title"] == "Internal"
assert organizers_by_name["Internal"]["externalUrl"] == ""
assert organizers_by_name["External"]["association"] is None
assert organizers_by_name["External"]["externalUrl"] == "https://external.example.com"
assert len(event["occurrences"]) == 2
venue_occ = next(o for o in event["occurrences"] if o["venue"] is not None)
custom_occ = next(o for o in event["occurrences"] if o["venueCustom"])
assert venue_occ["venueCustom"] == ""
assert venue_occ["venue"]["title"]
assert custom_occ["venue"] is None
assert custom_occ["venueCustom"] == "Frederikkeplassen"
venue_occ_db = comprehensive_event.occurrences.exclude(venue=None).get()
custom_occ_db = comprehensive_event.occurrences.exclude(venue_custom="").get()
assert datetime.fromisoformat(venue_occ["start"]) == venue_occ_db.start
assert datetime.fromisoformat(venue_occ["end"]) == venue_occ_db.end
assert datetime.fromisoformat(custom_occ["start"]) == custom_occ_db.start
assert datetime.fromisoformat(custom_occ["end"]) == custom_occ_db.end