mirror of
https://github.com/pretix/pretix.git
synced 2026-05-17 17:14:04 +00:00
1258 lines
36 KiB
Python
1258 lines
36 KiB
Python
#
|
|
# This file is part of pretix (Community Edition).
|
|
#
|
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
|
#
|
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
|
# this file, see <https://pretix.eu/about/en/license>.
|
|
#
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
|
# <https://www.gnu.org/licenses/>.
|
|
#
|
|
"""
|
|
E2E Test Configuration for Pretix Widget with Playwright
|
|
|
|
This module provides pytest fixtures for end-to-end testing of the pretix widget
|
|
using Playwright. It integrates Playwright with Django's test infrastructure.
|
|
"""
|
|
|
|
# TODO dev server websocket does not work, but is this relevant?
|
|
|
|
import os
|
|
import subprocess
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from urllib.error import URLError
|
|
from urllib.request import urlopen
|
|
|
|
import pytest
|
|
from django_scopes import scopes_disabled
|
|
from playwright.sync_api import ( # noqa: F401
|
|
Browser, BrowserContext, Page, expect,
|
|
)
|
|
|
|
from pretix.base.models import (
|
|
Event, Item, ItemVariation, Organizer, Quota, SubEvent, Voucher,
|
|
)
|
|
|
|
# Allow Django ORM operations in async context (required for Playwright integration)
|
|
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
|
|
|
|
|
def _future_dt(days=30, hour=10, minute=0):
|
|
"""Build a future UTC datetime with a fixed time-of-day.
|
|
|
|
Uses a relative date so tests don't expire, but pins the time
|
|
component so results are deterministic regardless of when tests run.
|
|
"""
|
|
d = date.today() + timedelta(days=days)
|
|
return datetime(d.year, d.month, d.day, hour, minute, tzinfo=timezone.utc)
|
|
|
|
|
|
# ============================================================================
|
|
# Widget Asset Configuration (old Vue2 / new Vite / Vite dev server)
|
|
# ============================================================================
|
|
|
|
PROJECT_ROOT = os.path.join(
|
|
os.path.dirname(__file__),
|
|
'../../..'
|
|
)
|
|
|
|
VITE_DEV_PORT = 5180
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _widget_assets():
|
|
"""
|
|
Build or check the widget JS depending on env vars.
|
|
|
|
- Default: old Vue2 widget (no build needed)
|
|
- PRETIX_WIDGET_VITE=1: run vite build, Django serves the output
|
|
- PRETIX_WIDGET_VITE_DEV=1: uses your already-running vite dev server
|
|
"""
|
|
if os.environ.get("PRETIX_WIDGET_VITE_DEV"):
|
|
try:
|
|
urlopen(f'http://localhost:{VITE_DEV_PORT}/', timeout=2)
|
|
except (URLError, OSError):
|
|
raise RuntimeError(
|
|
f'PRETIX_WIDGET_VITE_DEV is set but no Vite dev server found on port {VITE_DEV_PORT}. '
|
|
f'Start it with: npm run dev:widget'
|
|
)
|
|
yield
|
|
elif os.environ.get("PRETIX_WIDGET_VITE"):
|
|
subprocess.check_call(['npm', 'run', 'build:widget'], cwd=PROJECT_ROOT)
|
|
yield
|
|
else:
|
|
yield # Old widget, no build needed
|
|
|
|
|
|
# ============================================================================
|
|
# Playwright Configuration
|
|
# ============================================================================
|
|
|
|
@pytest.fixture(scope="session")
|
|
def browser_context_args(browser_context_args):
|
|
"""Configure browser context for all tests."""
|
|
return {
|
|
**browser_context_args,
|
|
"viewport": {"width": 1280, "height": 720},
|
|
"locale": "en-US",
|
|
"timezone_id": "Europe/Berlin",
|
|
# Enable video recording for debugging (optional)
|
|
# "record_video_dir": "test-results/videos/",
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def browser_type_launch_args(browser_type_launch_args):
|
|
"""Configure browser launch arguments."""
|
|
return {
|
|
**browser_type_launch_args,
|
|
# Uncomment for debugging
|
|
# "headless": False,
|
|
# "slow_mo": 500, # Slow down operations by 500ms
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Django Live Server Fixtures
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def live_server_url(live_server, settings):
|
|
"""
|
|
Get the live server URL.
|
|
|
|
Uses pytest-django's built-in live_server fixture which starts
|
|
a Django development server for E2E tests.
|
|
"""
|
|
# Enable django-compressor for on-the-fly SCSS compilation
|
|
settings.COMPRESS_ENABLED = True
|
|
settings.COMPRESS_OFFLINE = False # Compile on-the-fly, not from cache
|
|
|
|
# Re-enable SCSS precompilers (disabled in test settings)
|
|
from pretix.testutils.settings import COMPRESS_PRECOMPILERS_ORIGINAL
|
|
settings.COMPRESS_PRECOMPILERS = COMPRESS_PRECOMPILERS_ORIGINAL
|
|
|
|
# Fix cache backend for compression
|
|
settings.COMPRESS_CACHE_BACKEND = 'default'
|
|
|
|
# Add testcache to CACHES if needed (for compatibility)
|
|
if 'testcache' not in settings.CACHES:
|
|
settings.CACHES['testcache'] = {
|
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
|
}
|
|
|
|
settings.SITE_URL = live_server.url
|
|
|
|
# Enable Vite widget if requested via env var
|
|
if os.environ.get("PRETIX_WIDGET_VITE") or os.environ.get("PRETIX_WIDGET_VITE_DEV"):
|
|
settings.PRETIX_WIDGET_VITE = True
|
|
|
|
return live_server.url
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Organizers and Events
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def organizer(db):
|
|
"""
|
|
Create an organizer for widget tests.
|
|
Reuses the same pattern as existing API tests.
|
|
"""
|
|
return Organizer.objects.create(
|
|
name='Test Organizer',
|
|
slug='testorg',
|
|
plugins='pretix.plugins.banktransfer,pretix.plugins.stripe'
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def event(organizer):
|
|
"""Create a basic event for widget tests."""
|
|
event = Event.objects.create(
|
|
organizer=organizer,
|
|
name='Test Event',
|
|
slug='testevent',
|
|
date_from=_future_dt(days=30, hour=10),
|
|
date_to=_future_dt(days=30, hour=18),
|
|
currency='EUR',
|
|
live=True,
|
|
testmode=False,
|
|
plugins='pretix.plugins.banktransfer',
|
|
)
|
|
event.set_defaults()
|
|
event.settings.set('timezone', 'Europe/Berlin')
|
|
event.settings.set('locale', 'en')
|
|
event.settings.set('locales', ['en'])
|
|
return event
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def items(event):
|
|
"""Create basic test items/products."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
items = []
|
|
|
|
# Create a proper category
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Tickets',
|
|
position=0
|
|
)
|
|
|
|
# General Admission ticket
|
|
item1 = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='General Admission',
|
|
default_price=Decimal('50.00'),
|
|
description='Standard entry ticket',
|
|
active=True,
|
|
)
|
|
items.append(item1)
|
|
|
|
# VIP ticket
|
|
item2 = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='VIP Ticket',
|
|
default_price=Decimal('150.00'),
|
|
description='VIP access with special perks',
|
|
active=True,
|
|
)
|
|
items.append(item2)
|
|
|
|
# Create quotas for each item
|
|
for item in items:
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name=f'{item.name} Quota',
|
|
size=100,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return items
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Items with Variations
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_with_variations(event):
|
|
"""Create an item with size variations (S, M, L, XL)."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
# Create category for the item
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Merchandise',
|
|
position=1
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Event T-Shirt',
|
|
default_price=Decimal('25.00'),
|
|
description='Official event t-shirt',
|
|
active=True,
|
|
)
|
|
|
|
variations = []
|
|
sizes_and_prices = [
|
|
('Small', Decimal('20.00')),
|
|
('Medium', Decimal('25.00')),
|
|
('Large', Decimal('25.00')),
|
|
('X-Large', Decimal('30.00')),
|
|
]
|
|
|
|
for size, price in sizes_and_prices:
|
|
var = ItemVariation.objects.create(
|
|
item=item,
|
|
value=size,
|
|
default_price=price,
|
|
)
|
|
variations.append(var)
|
|
|
|
# Create quota for all variations
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='T-Shirt Quota',
|
|
size=50,
|
|
)
|
|
# Add both the item AND the variations to the quota
|
|
quota.items.add(item)
|
|
for var in variations:
|
|
quota.variations.add(var)
|
|
|
|
return item, variations
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_single_select(event):
|
|
"""Create an item with max_per_order=1 (should show checkbox)."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='VIP',
|
|
position=2
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='VIP Pass',
|
|
default_price=Decimal('500.00'),
|
|
description='Limited VIP pass - one per customer',
|
|
active=True,
|
|
max_per_order=1,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='VIP Quota',
|
|
size=10,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_free_price(event):
|
|
"""Create an item with pay-what-you-want pricing."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Donations',
|
|
position=3
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Donation',
|
|
default_price=Decimal('10.00'),
|
|
description='Support our cause',
|
|
active=True,
|
|
free_price=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Donation Quota',
|
|
size=999,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_sold_out(event):
|
|
"""Create a sold out item."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Early Bird',
|
|
position=4
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Early Bird Ticket',
|
|
default_price=Decimal('30.00'),
|
|
description='Sold out!',
|
|
active=True,
|
|
)
|
|
|
|
# Create quota with size=0 (sold out)
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Early Bird Quota',
|
|
size=0,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_free(event):
|
|
"""Create a free item (price = 0.00)."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Free Stuff',
|
|
position=10
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Free Gift',
|
|
default_price=Decimal('0.00'),
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Free Gift Quota',
|
|
size=100,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_with_decimals(event):
|
|
"""Create an item with non-zero decimal price."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Test Category',
|
|
position=11
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Half Price Item',
|
|
default_price=Decimal('12.50'),
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Half Price Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_with_tax(event):
|
|
"""Create an item with tax rule."""
|
|
from pretix.base.models import ItemCategory, TaxRule
|
|
|
|
# Create tax rule
|
|
tax_rule = TaxRule.objects.create(
|
|
event=event,
|
|
name='VAT',
|
|
rate=Decimal('19.00'), # 19% VAT
|
|
)
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Taxed Items',
|
|
position=12
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Taxed Product',
|
|
default_price=Decimal('100.00'),
|
|
tax_rule=tax_rule,
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Taxed Product Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Edge Cases
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_min_order(event):
|
|
"""Create an item with min_per_order=2."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Group Tickets',
|
|
position=13
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Group Pass',
|
|
default_price=Decimal('40.00'),
|
|
active=True,
|
|
min_per_order=2,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Group Pass Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_special_chars(event):
|
|
"""Create an item with special characters in the name."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Spezial',
|
|
position=14
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Böhm & Söhne Konzert',
|
|
default_price=Decimal('55.00'),
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Special Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Categories
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def items_with_category_description(event):
|
|
"""Create items with a category that has a description."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Tickets',
|
|
description='Early bird tickets available',
|
|
position=0
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Early Bird',
|
|
default_price=Decimal('35.00'),
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Early Bird Quota',
|
|
size=100,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return [item]
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def items_multiple_categories(event):
|
|
"""Create items in multiple categories to test grouping and ordering."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
cat_music = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Music',
|
|
position=0
|
|
)
|
|
|
|
cat_food = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Food & Drink',
|
|
position=1
|
|
)
|
|
|
|
item1 = Item.objects.create(
|
|
event=event,
|
|
category=cat_music,
|
|
name='Concert Ticket',
|
|
default_price=Decimal('75.00'),
|
|
active=True,
|
|
)
|
|
|
|
item2 = Item.objects.create(
|
|
event=event,
|
|
category=cat_food,
|
|
name='Food Pass',
|
|
default_price=Decimal('25.00'),
|
|
active=True,
|
|
)
|
|
|
|
for item in [item1, item2]:
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name=f'{item.name} Quota',
|
|
size=100,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return [item1, item2]
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Vouchers
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def voucher(event, items):
|
|
"""Create a voucher for the event."""
|
|
voucher = Voucher.objects.create(
|
|
event=event,
|
|
code='TESTCODE2024',
|
|
max_usages=10,
|
|
price_mode='none',
|
|
)
|
|
# Clear the vouchers_exist cache so the widget picks it up
|
|
event.get_cache().delete('vouchers_exist')
|
|
return voucher
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def voucher_with_item(event, items):
|
|
"""Create a voucher tied to a specific item."""
|
|
item = items[0]
|
|
voucher = Voucher.objects.create(
|
|
event=event,
|
|
code='ITEMVOUCHER',
|
|
max_usages=5,
|
|
price_mode='percent',
|
|
value=Decimal('20.00'), # 20% off
|
|
item=item,
|
|
)
|
|
event.get_cache().delete('vouchers_exist')
|
|
return voucher
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Waiting List
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_sold_out_with_waitinglist(event):
|
|
"""Create a sold out item with waiting list enabled."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
# Enable waiting list on the event
|
|
event.settings.set('waiting_list_enabled', True)
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Sold Out',
|
|
position=20
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Sold Out Concert',
|
|
default_price=Decimal('80.00'),
|
|
active=True,
|
|
allow_waitinglist=True,
|
|
)
|
|
|
|
# Create quota with size=0 (sold out)
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Sold Out Quota',
|
|
size=0,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Event Series
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def event_series(organizer):
|
|
"""Create an event series with multiple subevents, items, and quotas."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
event = Event.objects.create(
|
|
organizer=organizer,
|
|
name='Concert Series',
|
|
slug='concert-series',
|
|
date_from=_future_dt(days=30, hour=19),
|
|
has_subevents=True,
|
|
currency='EUR',
|
|
live=True,
|
|
plugins='pretix.plugins.banktransfer',
|
|
)
|
|
event.set_defaults()
|
|
event.settings.set('timezone', 'Europe/Berlin')
|
|
event.settings.set('locale', 'en')
|
|
event.settings.set('locales', ['en'])
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Tickets',
|
|
position=0
|
|
)
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Concert Ticket',
|
|
default_price=Decimal('45.00'),
|
|
active=True,
|
|
)
|
|
|
|
subevents = []
|
|
base_date = _future_dt(days=30, hour=19)
|
|
|
|
for i in range(15):
|
|
se = SubEvent.objects.create(
|
|
event=event,
|
|
name=f'Concert Night {i + 1}',
|
|
date_from=base_date + timedelta(days=i * 2),
|
|
date_to=base_date + timedelta(days=i * 2, hours=2),
|
|
active=True,
|
|
)
|
|
subevents.append(se)
|
|
|
|
# Each subevent needs its own quota
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name=f'Concert {i + 1} Quota',
|
|
size=100,
|
|
subevent=se,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return event, subevents
|
|
|
|
|
|
# ============================================================================
|
|
# Widget Helper Fixtures
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def widget_page(page):
|
|
"""
|
|
Enhanced page fixture with widget-specific helper methods.
|
|
|
|
Provides convenience methods for common widget interactions.
|
|
"""
|
|
class WidgetPage:
|
|
def __init__(self, page: Page):
|
|
self.page = page
|
|
|
|
def goto(
|
|
self,
|
|
live_server_url: str,
|
|
org_slug: str,
|
|
event_slug: str,
|
|
wait=True,
|
|
**widget_attrs
|
|
):
|
|
"""
|
|
Navigate to a test page with widget embedded and wait for it to load.
|
|
|
|
Uses a Django view that serves an HTML page with the pretix
|
|
widget embedded, simulating how it would be used on a customer's
|
|
website.
|
|
|
|
Extra keyword arguments are passed as query params to the view,
|
|
which converts them to widget attributes. For boolean attributes
|
|
(like disable-vouchers), pass an empty string as value.
|
|
|
|
Set wait=False to skip waiting for the widget to load (useful for
|
|
tests that need to observe loading/error states).
|
|
"""
|
|
# Navigate to the test view URL
|
|
test_url = f"{live_server_url}/widget-test/{org_slug}/{event_slug}/"
|
|
if widget_attrs:
|
|
from urllib.parse import urlencode
|
|
test_url += '?' + urlencode(widget_attrs)
|
|
self.page.goto(test_url)
|
|
if wait:
|
|
self.wait_for_widget_load()
|
|
return self
|
|
|
|
def wait_for_widget_load(self):
|
|
"""Wait for widget to finish loading."""
|
|
self.page.wait_for_selector('.pretix-widget', timeout=15000)
|
|
# Wait for loading spinner to be hidden (widget has rendered content)
|
|
self.page.locator('.pretix-widget-loading').wait_for(state='hidden', timeout=15000)
|
|
return self
|
|
|
|
def wait_for_loading_indicator(self, timeout=15000):
|
|
"""Wait for the loading indicator to appear and then disappear (display: none)."""
|
|
loading = self.page.locator('.pretix-widget-loading')
|
|
loading.wait_for(state='visible', timeout=timeout)
|
|
loading.wait_for(state='hidden', timeout=timeout)
|
|
return self
|
|
|
|
def select_item_quantity(self, item_name: str, quantity: int):
|
|
"""Select quantity for an item by name."""
|
|
# Find the item row
|
|
item_row = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
|
|
|
|
# Find number input within that row
|
|
number_input = item_row.locator('input[type="number"]').first
|
|
number_input.wait_for(state='visible', timeout=5000)
|
|
if number_input.count() > 0:
|
|
number_input.fill(str(quantity))
|
|
number_input.dispatch_event('change')
|
|
else:
|
|
# Maybe it's a checkbox (order_max=1)
|
|
checkbox = item_row.locator('input[type="checkbox"]').first
|
|
if quantity > 0:
|
|
checkbox.check()
|
|
return self
|
|
|
|
def select_variation_quantity(self, item_name: str, variation_name: str, quantity: int):
|
|
"""Select quantity for a specific variation."""
|
|
# Find item
|
|
item = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
|
|
|
|
# Find variation within item using exact text match to avoid
|
|
# "Large" matching "X-Large"
|
|
variation = item.locator(
|
|
f'.pretix-widget-variation:has(strong:text-is("{variation_name}"))'
|
|
)
|
|
|
|
# Find input
|
|
input_field = variation.locator('input[type="number"]').first
|
|
input_field.fill(str(quantity))
|
|
input_field.dispatch_event('change')
|
|
return self
|
|
|
|
def click_buy_button(self):
|
|
"""Click the buy/register button."""
|
|
buy_button = self.page.locator("""
|
|
.pretix-widget-action button:has-text("Buy"),
|
|
.pretix-widget-action button:has-text("Register")
|
|
""")
|
|
buy_button.first.click()
|
|
return self
|
|
|
|
def wait_for_iframe_checkout(self):
|
|
"""Wait for checkout iframe to appear."""
|
|
self.page.wait_for_selector('.pretix-widget-frame-shown', timeout=15000)
|
|
# Wait for iframe to load
|
|
self.page.wait_for_function(
|
|
"""() => {
|
|
const iframe = document.querySelector('iframe[name^="pretix-widget-"]');
|
|
return iframe && iframe.src !== 'about:blank';
|
|
}""",
|
|
timeout=15000
|
|
)
|
|
iframe = self.page.frame_locator('iframe[name^="pretix-widget-"]')
|
|
return iframe
|
|
|
|
def close_iframe(self):
|
|
"""Close the checkout iframe and wait for the widget to reload.
|
|
|
|
The widget triggers a reload() when the iframe is closed
|
|
(without incrementing the loading counter), so we wait for
|
|
the XHR response to complete before returning.
|
|
"""
|
|
close_btn = self.page.locator('.pretix-widget-frame-close button')
|
|
# Wait for the reload XHR that fires when the iframe closes
|
|
with self.page.expect_response(
|
|
lambda r: 'widget/product_list' in r.url,
|
|
timeout=15000
|
|
):
|
|
close_btn.click()
|
|
self.page.locator('.pretix-widget-frame-shown').wait_for(
|
|
state='detached', timeout=5000
|
|
)
|
|
return self
|
|
|
|
def wait_for_view(self, selector: str, timeout=15000):
|
|
"""Wait for a specific element to appear after a view switch."""
|
|
self.page.locator(selector).first.wait_for(state='visible', timeout=timeout)
|
|
return self
|
|
|
|
def expand_variations(self, item_name: str):
|
|
"""Click the 'Show variants' button for an item."""
|
|
item = self.page.locator(f'.pretix-widget-item:has-text("{item_name}")')
|
|
toggle_btn = item.locator('button:has-text("Show variants"), button:has-text("variants")')
|
|
toggle_btn.click()
|
|
return self
|
|
|
|
def goto_button_test_page(
|
|
self,
|
|
live_server_url: str,
|
|
org_slug: str,
|
|
event_slug: str,
|
|
**query_params
|
|
):
|
|
"""Navigate to a test page with pretix-button embedded."""
|
|
from urllib.parse import urlencode
|
|
test_url = f"{live_server_url}/button-test/{org_slug}/{event_slug}/"
|
|
if query_params:
|
|
test_url += '?' + urlencode(query_params)
|
|
self.page.goto(test_url)
|
|
return self
|
|
|
|
return WidgetPage(page)
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Availability States
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_require_voucher(event):
|
|
"""Create an item that requires a voucher to purchase."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Voucher Only',
|
|
position=30
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Exclusive Pass',
|
|
default_price=Decimal('200.00'),
|
|
active=True,
|
|
require_voucher=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Exclusive Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_low_stock(event):
|
|
"""Create an item with low stock (quota_left visible)."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Limited',
|
|
position=31
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Last Chance Ticket',
|
|
default_price=Decimal('65.00'),
|
|
active=True,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Limited Quota',
|
|
size=3,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
# Enable "show quota left" on the event
|
|
event.settings.set('show_quota_left', True)
|
|
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_not_yet_available(event):
|
|
"""Create an item that is not yet available (future available_from)."""
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Coming Soon',
|
|
position=32
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Future Ticket',
|
|
default_price=Decimal('45.00'),
|
|
active=True,
|
|
available_from=_future_dt(days=365),
|
|
available_from_mode='info', # Show as "not yet available" instead of hiding
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Future Quota',
|
|
size=100,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
# ============================================================================
|
|
# Test Data Fixtures - Items with Pictures
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
@scopes_disabled()
|
|
def item_with_picture(event):
|
|
"""Create an item with a product picture."""
|
|
import io
|
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from PIL import Image as PILImage
|
|
|
|
from pretix.base.models import ItemCategory
|
|
|
|
category = ItemCategory.objects.create(
|
|
event=event,
|
|
name='Gallery Items',
|
|
position=40
|
|
)
|
|
|
|
# Create a small test image (100x100 red square)
|
|
img = PILImage.new('RGB', (100, 100), color='red')
|
|
buf = io.BytesIO()
|
|
img.save(buf, format='PNG')
|
|
buf.seek(0)
|
|
|
|
picture_file = SimpleUploadedFile(
|
|
name='test_product.png',
|
|
content=buf.read(),
|
|
content_type='image/png'
|
|
)
|
|
|
|
item = Item.objects.create(
|
|
event=event,
|
|
category=category,
|
|
name='Art Print',
|
|
default_price=Decimal('35.00'),
|
|
description='Limited edition art print',
|
|
active=True,
|
|
picture=picture_file,
|
|
)
|
|
|
|
quota = Quota.objects.create(
|
|
event=event,
|
|
name='Art Print Quota',
|
|
size=50,
|
|
)
|
|
quota.items.add(item)
|
|
|
|
return item
|
|
|
|
|
|
# ============================================================================
|
|
# Cross-Browser Testing
|
|
# ============================================================================
|
|
|
|
@pytest.fixture(params=['chromium']) # Add 'firefox', 'webkit' when ready
|
|
def cross_browser_page(request, playwright):
|
|
"""
|
|
Test across multiple browsers.
|
|
|
|
Usage:
|
|
def test_widget_works_everywhere(cross_browser_page):
|
|
page = cross_browser_page
|
|
page.goto("...")
|
|
"""
|
|
browser_type = getattr(playwright, request.param)
|
|
browser = browser_type.launch()
|
|
context = browser.new_context()
|
|
page = context.new_page()
|
|
|
|
yield page
|
|
|
|
page.close()
|
|
context.close()
|
|
browser.close()
|
|
|
|
|
|
@pytest.fixture(scope='session', autouse=True)
|
|
def _register_widget_test_view():
|
|
"""
|
|
Register a test view that serves an HTML page with widget embedded.
|
|
|
|
This allows E2E tests to navigate to a real URL instead of using
|
|
set_content, which causes CORS issues.
|
|
"""
|
|
from django.http import HttpResponse
|
|
from django.urls import path
|
|
from django.views import View
|
|
|
|
from pretix.multidomain import maindomain_urlconf as urls
|
|
|
|
class WidgetTestView(View):
|
|
"""Serve HTML page with widget embedded for E2E testing."""
|
|
|
|
# Widget attributes that can be passed as query params
|
|
WIDGET_ATTRS = [
|
|
'items', 'categories', 'voucher', 'disable-vouchers',
|
|
'disable-iframe', 'subevent', 'list-type',
|
|
'display-event-info', 'skip-ssl-check',
|
|
]
|
|
|
|
def get(self, request, organizer, event):
|
|
base_url = f"{request.scheme}://{request.get_host()}"
|
|
event_url = f"{base_url}/{organizer}/{event}/"
|
|
widget_css = f"{base_url}/{organizer}/{event}/widget/v2.css"
|
|
|
|
if os.environ.get("PRETIX_WIDGET_VITE_DEV"):
|
|
script_tag = f'<script type="module" src="http://localhost:{VITE_DEV_PORT}/src/main.ts"></script>'
|
|
else:
|
|
widget_js = f"{base_url}/widget/v2.en.js"
|
|
script_tag = f'<script type="text/javascript" src="{widget_js}" async crossorigin></script>'
|
|
|
|
# Build extra attributes from query params
|
|
extra_attrs = ''
|
|
for attr in self.WIDGET_ATTRS:
|
|
val = request.GET.get(attr)
|
|
if val is not None:
|
|
if val == '':
|
|
# Boolean attribute (e.g., disable-vouchers)
|
|
extra_attrs += f' {attr}'
|
|
else:
|
|
extra_attrs += f' {attr}="{val}"'
|
|
|
|
# Always add skip-ssl-check so iframe checkout works on HTTP
|
|
if 'skip-ssl-check' not in extra_attrs:
|
|
extra_attrs += ' skip-ssl-check'
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Widget Test</title>
|
|
<link rel="stylesheet" type="text/css" href="{widget_css}" crossorigin>
|
|
</head>
|
|
<body>
|
|
<pretix-widget event="{event_url}"{extra_attrs}></pretix-widget>
|
|
{script_tag}
|
|
</body>
|
|
</html>"""
|
|
resp = HttpResponse(html, content_type='text/html')
|
|
resp['Content-Security-Policy'] = "script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'"
|
|
return resp
|
|
|
|
class ButtonTestView(View):
|
|
"""Serve HTML page with pretix-button element for E2E testing."""
|
|
|
|
def get(self, request, organizer, event):
|
|
base_url = f"{request.scheme}://{request.get_host()}"
|
|
event_url = f"{base_url}/{organizer}/{event}/"
|
|
widget_css = f"{base_url}/{organizer}/{event}/widget/v2.css"
|
|
|
|
if os.environ.get("PRETIX_WIDGET_VITE_DEV"):
|
|
script_tag = f'<script type="module" src="http://localhost:{VITE_DEV_PORT}/src/main.ts"></script>'
|
|
else:
|
|
widget_js = f"{base_url}/widget/v2.en.js"
|
|
script_tag = f'<script type="text/javascript" src="{widget_js}" async crossorigin></script>'
|
|
|
|
# Build extra attributes from query params
|
|
extra_attrs = ''
|
|
for attr in ['items', 'voucher', 'subevent', 'disable-iframe']:
|
|
val = request.GET.get(attr)
|
|
if val is not None:
|
|
if val == '':
|
|
extra_attrs += f' {attr}'
|
|
else:
|
|
extra_attrs += f' {attr}="{val}"'
|
|
|
|
button_text = request.GET.get('button-text', 'Buy tickets!')
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Button Test</title>
|
|
<link rel="stylesheet" type="text/css" href="{widget_css}" crossorigin>
|
|
</head>
|
|
<body>
|
|
<pretix-button event="{event_url}"{extra_attrs} skip-ssl-check>{button_text}</pretix-button>
|
|
{script_tag}
|
|
</body>
|
|
</html>"""
|
|
resp = HttpResponse(html, content_type='text/html')
|
|
resp['Content-Security-Policy'] = "script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'"
|
|
return resp
|
|
|
|
# Add URL patterns
|
|
test_pattern = path(
|
|
'widget-test/<str:organizer>/<str:event>/',
|
|
WidgetTestView.as_view()
|
|
)
|
|
button_pattern = path(
|
|
'button-test/<str:organizer>/<str:event>/',
|
|
ButtonTestView.as_view()
|
|
)
|
|
|
|
# Insert at beginning of URL patterns
|
|
if hasattr(urls, 'urlpatterns'):
|
|
urls.urlpatterns.insert(0, test_pattern)
|
|
urls.urlpatterns.insert(0, button_pattern)
|