523 lines
18 KiB
Python
523 lines
18 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 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_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
|