From 843062bb13d0e461d4d2afd876321ac088fa1886 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Tue, 19 May 2026 04:42:27 +0200 Subject: [PATCH] dnscms: add more tests --- dnscms/tests/conftest.py | 58 +++++ dnscms/tests/test_events.py | 337 ++++++++++++++++++++++++++++-- dnscms/tests/test_generic.py | 71 +++++++ dnscms/tests/test_graphql.py | 32 --- dnscms/tests/test_news.py | 26 +++ dnscms/tests/test_openinghours.py | 91 ++++++++ 6 files changed, 565 insertions(+), 50 deletions(-) create mode 100644 dnscms/tests/test_generic.py create mode 100644 dnscms/tests/test_news.py create mode 100644 dnscms/tests/test_openinghours.py diff --git a/dnscms/tests/conftest.py b/dnscms/tests/conftest.py index f42a214..88b5188 100644 --- a/dnscms/tests/conftest.py +++ b/dnscms/tests/conftest.py @@ -5,10 +5,35 @@ import pytest import wagtail_factories from wagtail.models import Page +from associations.models import AssociationIndex, AssociationPage from events.models import EventIndex, EventPage +from generic.models import GenericPage +from images.models import CustomImage +from news.models import NewsIndex, NewsPage from venues.models import VenueIndex, VenuePage +class CustomImageFactory(wagtail_factories.ImageFactory): + class Meta: + model = CustomImage + + +class AssociationIndexFactory(wagtail_factories.PageFactory): + title = factory.Sequence(lambda n: f"Associations {n}") + lead = "

Foreninger og utvalg.

" + + class Meta: + model = AssociationIndex + + +class AssociationPageFactory(wagtail_factories.PageFactory): + title = factory.Sequence(lambda n: f"Association {n}") + excerpt = "Et utdrag." + + class Meta: + model = AssociationPage + + class EventIndexFactory(wagtail_factories.PageFactory): title = factory.Sequence(lambda n: f"Events {n}") @@ -23,6 +48,29 @@ class EventPageFactory(wagtail_factories.PageFactory): model = EventPage +class GenericPageFactory(wagtail_factories.PageFactory): + title = factory.Sequence(lambda n: f"Page {n}") + lead = "

Ingress.

" + + class Meta: + model = GenericPage + + +class NewsIndexFactory(wagtail_factories.PageFactory): + title = factory.Sequence(lambda n: f"News {n}") + + class Meta: + model = NewsIndex + + +class NewsPageFactory(wagtail_factories.PageFactory): + title = factory.Sequence(lambda n: f"Article {n}") + excerpt = "Et utdrag." + + class Meta: + model = NewsPage + + class VenueIndexFactory(wagtail_factories.PageFactory): title = factory.Sequence(lambda n: f"Venues {n}") @@ -56,6 +104,16 @@ def event_index(home_page): return EventIndexFactory(parent=home_page) +@pytest.fixture +def news_index(home_page): + return NewsIndexFactory(parent=home_page) + + +@pytest.fixture +def association_index(home_page): + return AssociationIndexFactory(parent=home_page) + + @pytest.fixture def venue(home_page): venue_index = VenueIndexFactory(parent=home_page) diff --git a/dnscms/tests/test_events.py b/dnscms/tests/test_events.py index 56d17b7..a415d75 100644 --- a/dnscms/tests/test_events.py +++ b/dnscms/tests/test_events.py @@ -1,16 +1,21 @@ -from datetime import timedelta +from datetime import datetime, timedelta import pytest from django.core.exceptions import ValidationError from django.utils import timezone from events.models import ( + EventCategory, EventOccurrence, EventOrganizer, EventOrganizerLink, EventPage, ) -from tests.conftest import EventPageFactory +from tests.conftest import ( + AssociationPageFactory, + CustomImageFactory, + EventPageFactory, +) def test_eventpage_clean_unsets_specific_pricing_when_free(): @@ -30,6 +35,58 @@ def test_eventpage_clean_unsets_specific_pricing_when_free(): 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( @@ -57,14 +114,10 @@ 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" - ) + 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" - ) + EventOccurrence.objects.create(event=future, start=now + timedelta(days=7), venue_custom="New") results = list(EventPage.objects.live().future().order_by("next_occurrence")) @@ -72,17 +125,265 @@ def test_eventpage_manager_future_filters_past_and_annotates(event_index): assert results[0].next_occurrence is not None -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") +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) - EventOrganizerLink.objects.create(event=event, organizer=org_a) - EventOrganizerLink.objects.create(event=event, organizer=org_b) + event = EventPageFactory(parent=event_index, title="Late today") + EventOccurrence.objects.create(event=event, start=late_today, venue_custom="X") - event = EventPage.objects.get(pk=event.pk) - assert event.organizer_links.count() == 2 + assert event.pk in EventPage.objects.future().values_list("pk", flat=True) - event.clean() - assert event.organizer_links.count() == 1 +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_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") + + +@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="

Ingress.

", + body=[("paragraph", "

Body content.

")], + 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 diff --git a/dnscms/tests/test_generic.py b/dnscms/tests/test_generic.py new file mode 100644 index 0000000..1eaeb01 --- /dev/null +++ b/dnscms/tests/test_generic.py @@ -0,0 +1,71 @@ +from generic.models import GenericPage +from tests.conftest import GenericPageFactory + + +def test_generic_page_persists_via_factory(home_page): + page = GenericPageFactory( + parent=home_page, + title="Om oss", + slug="om-oss", + lead="

Ingress.

", + body=[("paragraph", "

Body content.

")], + pig="drink", + ) + + reloaded = GenericPage.objects.get(pk=page.pk) + assert reloaded.title == "Om oss" + assert reloaded.slug == "om-oss" + assert "Ingress." in reloaded.lead + assert reloaded.pig == "drink" + assert reloaded.body[0].block_type == "paragraph" + + +def test_generic_page_allows_recursive_children(home_page): + parent = GenericPageFactory(parent=home_page, title="Parent", slug="parent") + child = GenericPageFactory(parent=parent, title="Child", slug="child") + + assert child.get_parent().specific == parent + assert list(parent.get_children().specific()) == [child] + + +def test_graphql_generic_page_query(home_page, graphql_post): + GenericPageFactory( + parent=home_page, + title="Om oss", + slug="om-oss", + lead="

Ingress text.

", + body=[("paragraph", "

Body content.

")], + pig="drink", + ) + + response, body = graphql_post( + """ + query { + page(slug: "om-oss", contentType: "generic.GenericPage") { + title + slug + ... on GenericPage { + lead + pig + body { + blockType + field + ... on RichTextBlock { + value + } + } + } + } + } + """ + ) + + assert response.status_code == 200 + assert "errors" not in body, body + data = body["data"]["page"] + assert data["title"] == "Om oss" + assert data["slug"] == "om-oss" + assert "Ingress text." in data["lead"] + assert data["pig"] == "drink" + assert data["body"][0]["blockType"] == "RichTextBlock" + assert "Body content." in data["body"][0]["value"] diff --git a/dnscms/tests/test_graphql.py b/dnscms/tests/test_graphql.py index ed5d80c..edf92c3 100644 --- a/dnscms/tests/test_graphql.py +++ b/dnscms/tests/test_graphql.py @@ -1,38 +1,6 @@ -from datetime import timedelta - -from django.utils import timezone - -from events.models import EventOccurrence -from tests.conftest import EventPageFactory - - def test_graphql_endpoint_responds(db, graphql_post): response, body = graphql_post("{ __schema { queryType { name } } }") assert response.status_code == 200 assert "errors" not in body assert body["data"]["__schema"]["queryType"]["name"] == "Query" - - -def test_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 diff --git a/dnscms/tests/test_news.py b/dnscms/tests/test_news.py new file mode 100644 index 0000000..1594fd3 --- /dev/null +++ b/dnscms/tests/test_news.py @@ -0,0 +1,26 @@ +from news.models import NewsPage +from tests.conftest import NewsPageFactory + + +def test_news_page_persists_via_factory(news_index): + page = NewsPageFactory(parent=news_index, title="Big news", excerpt="Short summary") + + reloaded = NewsPage.objects.get(pk=page.pk) + assert reloaded.title == "Big news" + assert reloaded.excerpt == "Short summary" + + +def test_graphql_news_index_query(news_index, graphql_post): + response, body = graphql_post( + """ + query { + newsIndex { + title + } + } + """ + ) + + assert response.status_code == 200 + assert "errors" not in body, body + assert body["data"]["newsIndex"]["title"] == news_index.title diff --git a/dnscms/tests/test_openinghours.py b/dnscms/tests/test_openinghours.py new file mode 100644 index 0000000..d9346da --- /dev/null +++ b/dnscms/tests/test_openinghours.py @@ -0,0 +1,91 @@ +import datetime + +import pytest + +from openinghours.models import OpeningHoursItem, OpeningHoursSet + + +@pytest.fixture +def opening_hours_set(db): + return OpeningHoursSet.objects.create( + name="Vanlige åpningstider", + effective_from=datetime.date(2025, 1, 1), + ) + + +def test_opening_hours_set_str_with_end_date(): + ohs = OpeningHoursSet( + name="Sommer", + effective_from=datetime.date(2025, 6, 1), + effective_to=datetime.date(2025, 8, 31), + ) + assert str(ohs) == "Sommer (2025-06-01 - 2025-08-31)" + + +def test_opening_hours_set_str_uses_infinity_when_open_ended(): + ohs = OpeningHoursSet( + name="Forever", + effective_from=datetime.date(2025, 1, 1), + effective_to=None, + ) + assert str(ohs) == "Forever (2025-01-01 - ∞)" + + +def test_opening_hours_streamfield_week_roundtrip(opening_hours_set): + OpeningHoursItem.objects.create( + opening_hours_set=opening_hours_set, + function="glassbaren", + week=[ + ( + "week", + { + "monday": { + "time_from": datetime.time(15, 0), + "time_to": datetime.time(23, 0), + "custom": "", + }, + "tuesday": {"time_from": None, "time_to": None, "custom": "Stengt"}, + "wednesday": {"time_from": None, "time_to": None, "custom": ""}, + "thursday": {"time_from": None, "time_to": None, "custom": ""}, + "friday": {"time_from": None, "time_to": None, "custom": ""}, + "saturday": {"time_from": None, "time_to": None, "custom": ""}, + "sunday": {"time_from": None, "time_to": None, "custom": ""}, + }, + ), + ], + ) + + reloaded = OpeningHoursSet.objects.get(pk=opening_hours_set.pk) + item = reloaded.items.get() + assert item.function == "glassbaren" + + week_block = item.week[0] + assert week_block.block_type == "week" + assert week_block.value["monday"]["time_from"] == datetime.time(15, 0) + assert week_block.value["monday"]["time_to"] == datetime.time(23, 0) + assert week_block.value["tuesday"]["custom"] == "Stengt" + + +def test_graphql_opening_hours_sets_query(db, graphql_post): + OpeningHoursSet.objects.create( + name="Sommer 2025", + effective_from=datetime.date(2025, 6, 1), + effective_to=datetime.date(2025, 8, 31), + ) + + response, body = graphql_post( + """ + query { + openingHoursSets { + name + effectiveFrom + effectiveTo + } + } + """ + ) + + assert response.status_code == 200 + assert "errors" not in body, body + names = [s["name"] for s in body["data"]["openingHoursSets"]] + assert "Sommer 2025" in names