diff --git a/src/setup.cfg b/src/setup.cfg index e633a99582..d02ab3b0da 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -18,7 +18,13 @@ skip_glob = data/**,make_testdata.py,wsgi.py,bootstrap,celery_app.py,pretix/sett [tool:pytest] DJANGO_SETTINGS_MODULE = tests.settings addopts = -rw +asyncio_mode = auto asyncio_default_fixture_loop_scope = function +# Playwright E2E test configuration +# Uncomment for debugging: --headed shows browser UI, --slowmo slows operations +# addopts = -rw --headed --slowmo 500 +# Browser selection (chromium, firefox, webkit) +# --browser chromium filterwarnings = error ignore:.*invalid escape sequence.*: diff --git a/src/tests/e2e/README.md b/src/tests/e2e/README.md new file mode 100644 index 0000000000..8adf1d4502 --- /dev/null +++ b/src/tests/e2e/README.md @@ -0,0 +1,487 @@ +# Pretix Widget E2E Tests + +End-to-end tests for the pretix widget using Python Playwright and pytest. + +## Overview + +These tests verify the complete widget experience from a user's perspective, including: +- Widget embedding and initialization +- Product browsing and variations +- Quantity controls and free price inputs +- Cart management and checkout flows +- Iframe/new tab integration +- Responsive behavior and accessibility + +## Installation + +### Prerequisites + +- Python 3.11+ +- Virtual environment activated +- Django test database configured + +### Setup + +1. **Activate the virtual environment:** + ```bash + source env/bin/activate + ``` + +2. **Install pytest-playwright:** + ```bash + pip install pytest-playwright + ``` + +3. **Install browser binaries:** + ```bash + # Install all browsers (Chromium, Firefox, WebKit) + python -m playwright install + + # Or install specific browsers only + python -m playwright install chromium + python -m playwright install firefox + ``` + +4. **Verify installation:** + ```bash + pytest src/tests/e2e/ --collect-only + ``` + +## Running Tests + +### Basic Usage + +```bash +# Run all E2E tests +pytest src/tests/e2e/ + +# Run specific test file +pytest src/tests/e2e/test_widget_embedding.py + +# Run specific test +pytest src/tests/e2e/test_widget_cart.py::TestCartBasics::test_add_to_cart_and_open_checkout +``` + +### Browser Selection + +```bash +# Run with specific browser (default: chromium) +pytest src/tests/e2e/ --browser firefox +pytest src/tests/e2e/ --browser webkit + +# Run with multiple browsers +pytest src/tests/e2e/ --browser chromium --browser firefox +``` + +### Debugging Options + +```bash +# Show browser UI (headed mode) +pytest src/tests/e2e/ --headed + +# Slow down operations for observation (milliseconds) +pytest src/tests/e2e/ --headed --slowmo 1000 + +# Generate video recordings +pytest src/tests/e2e/ --video on + +# Keep browser open on failure +pytest src/tests/e2e/ --headed --pdb + +# Generate screenshots on failure +pytest src/tests/e2e/ --screenshot on +``` + +### Parallel Execution + +```bash +# Run tests in parallel (requires pytest-xdist) +pip install pytest-xdist +pytest src/tests/e2e/ -n 4 +``` + +### Verbose Output + +```bash +# Show detailed output +pytest src/tests/e2e/ -v + +# Show print statements +pytest src/tests/e2e/ -s + +# Show all captured output even for passing tests +pytest src/tests/e2e/ -v -s --capture=no +``` + +## Test Organization + +``` +src/tests/e2e/ +├── conftest.py # Shared fixtures and configuration +├── test_widget_embedding.py # Widget loading and initialization +├── test_widget_variations.py # Product variations and expansion +├── test_widget_quantity_controls.py # Quantity inputs, checkboxes, free price +└── test_widget_cart.py # Cart management and checkout flow +``` + +### Key Fixtures + +Defined in `conftest.py`: + +- **`widget_organizer`** - Creates test organizer (testorg) +- **`widget_event`** - Creates test event (testevent) +- **`widget_items`** - Creates General Admission ($50) and VIP Ticket ($150) +- **`widget_item_with_variations`** - Creates t-shirt with S/M/L/XL sizes +- **`widget_item_single_select`** - Creates item with order_max=1 (checkbox) +- **`widget_item_free_price`** - Creates pay-what-you-want donation item +- **`widget_item_sold_out`** - Creates sold out item +- **`widget_event_series`** - Creates event series with 15 subevents +- **`widget_page`** - Enhanced page object with helper methods +- **`live_server_url`** - Django live server URL + +### WidgetPage Helper Methods + +The `widget_page` fixture provides convenient methods: + +```python +def test_example(widget_page, live_server_url, widget_event, widget_items): + # Navigate to event + widget_page.goto_event(live_server_url, 'testorg', 'testevent') + + # Wait for widget to load + widget_page.wait_for_widget_load() + + # Select item quantity + widget_page.select_item_quantity('General Admission', 2) + + # Expand variations + widget_page.expand_variations('Event T-Shirt') + + # Select variation quantity + widget_page.select_variation_quantity('Event T-Shirt', 'Medium', 1) + + # Click buy button + widget_page.click_buy_button() + + # Wait for iframe checkout + iframe = widget_page.wait_for_iframe_checkout() + + # Close iframe + widget_page.close_iframe() +``` + +## Common Test Patterns + +### Basic Widget Load Test + +```python +@pytest.mark.django_db +def test_widget_loads(page, live_server_url, widget_organizer, widget_event, widget_items, widget_page): + widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Verify content + expect(page.locator(f'text="{widget_event.name}"')).to_be_visible() +``` + +### Variation Interaction Test + +```python +@pytest.mark.django_db +def test_variations(page, live_server_url, widget_organizer, widget_event, widget_item_with_variations, widget_page): + item, variations = widget_item_with_variations + + widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand variations + widget_page.expand_variations(item.name) + + # Verify all variations visible + for variation in variations: + expect(page.locator(f'text="{variation.value}"')).to_be_visible() +``` + +### Cart Flow Test + +```python +@pytest.mark.django_db +def test_checkout(page, context, live_server_url, widget_organizer, widget_event, widget_items, widget_page): + widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Add items + widget_page.select_item_quantity(widget_items[0].name, 2) + widget_page.click_buy_button() + + # Wait for checkout to open + page.wait_for_timeout(2000) + + # Verify cookies set + cookies = context.cookies() + assert len(cookies) > 0 +``` + +## Debugging + +### View Browser in Action + +Run tests with `--headed` flag to see the browser: + +```bash +pytest src/tests/e2e/test_widget_embedding.py::TestWidgetEmbedding::test_widget_loads_successfully --headed +``` + +### Slow Down for Observation + +Use `--slowmo` to slow operations (milliseconds): + +```bash +pytest src/tests/e2e/ --headed --slowmo 1000 +``` + +### Interactive Debugging + +Use `--pdb` to drop into debugger on failure: + +```bash +pytest src/tests/e2e/test_widget_cart.py --headed --pdb +``` + +### Record Videos + +Generate video recordings of test runs: + +```bash +pytest src/tests/e2e/ --video on + +# Videos saved to: test-results/ +``` + +### Generate Trace Files + +For detailed debugging with Playwright Inspector: + +```bash +pytest src/tests/e2e/ --tracing on + +# View traces with: +playwright show-trace test-results/.../trace.zip +``` + +## Troubleshooting + +### Browser binaries not found + +**Error:** `playwright._impl._errors.Error: Executable doesn't exist` + +**Solution:** +```bash +python -m playwright install chromium +``` + +### Django database errors + +**Error:** `django.db.utils.OperationalError: no such table` + +**Solution:** +```bash +# Run migrations in test settings +DJANGO_SETTINGS_MODULE=tests.settings python manage.py migrate +``` + +### Live server not starting + +**Error:** `OSError: [Errno 98] Address already in use` + +**Solution:** Kill processes using the port or let pytest assign random ports automatically (default behavior). + +### Widget not loading + +**Issue:** Widget displays loading spinner indefinitely + +**Debug steps:** +1. Check browser console for JavaScript errors: + ```python + page.on("console", lambda msg: print(f"Console: {msg.text}")) + ``` + +2. Verify widget JavaScript is built: + ```bash + ls -l src/pretix/static/pretixpresale/widget/dist/ + ``` + +3. Check Django static files are served: + ```bash + DJANGO_SETTINGS_MODULE=tests.settings python manage.py collectstatic --noinput + ``` + +### Timeout errors + +**Error:** `playwright._impl._errors.TimeoutError: Timeout 10000ms exceeded` + +**Solution:** Increase timeout for specific operations: +```python +page.wait_for_selector('.pretix-widget', timeout=30000) +``` + +Or globally in conftest.py: +```python +@pytest.fixture +def page(context): + page = context.new_page() + page.set_default_timeout(30000) # 30 seconds + yield page + page.close() +``` + +### Cookie issues + +**Issue:** Cart cookies not being set + +**Debug:** +```python +# Print all cookies +cookies = context.cookies() +for cookie in cookies: + print(f"{cookie['name']}: {cookie['value']}") +``` + +Check domain/path settings match your test server. + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: E2E Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pytest-playwright + python -m playwright install --with-deps chromium + + - name: Run E2E tests + run: | + pytest src/tests/e2e/ --browser chromium --video on + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: test-results/ +``` + +## Configuration + +### setup.cfg + +Playwright configuration is in `/home/rash/Projects/pretix/src/setup.cfg`: + +```ini +[tool:pytest] +DJANGO_SETTINGS_MODULE = tests.settings +addopts = -rw +# Uncomment for debugging: +# addopts = -rw --headed --slowmo 500 +# --browser chromium +``` + +### Browser Context Args + +Customize in `conftest.py`: + +```python +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + return { + **browser_context_args, + "viewport": {"width": 1280, "height": 720}, + "locale": "en-US", + "timezone_id": "America/New_York", + } +``` + +## Writing New Tests + +### Test Structure + +```python +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestFeatureName: + """Test description.""" + + def test_specific_behavior( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Test should description. + + Given: initial state + When: action occurs + Then: expected outcome + """ + # Arrange + widget_page.goto_event(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Act + widget_page.select_item_quantity(widget_items[0].name, 1) + + # Assert + expect(page.locator('.some-element')).to_be_visible() +``` + +### Best Practices + +1. **Use descriptive test names** - `test_variation_quantity_updates_on_input` vs `test_var` +2. **Use WidgetPage helpers** - Encapsulate common interactions +3. **Wait for elements** - Use `expect().to_be_visible()` instead of `wait_for_timeout()` +4. **Mark Django tests** - Always use `@pytest.mark.django_db` when accessing database +5. **Clean test data** - Use fixtures, let pytest handle cleanup +6. **Verify user-visible behavior** - Test what users see, not implementation details + +## Related Documentation + +- [Playwright Python Docs](https://playwright.dev/python/) +- [pytest-playwright Plugin](https://github.com/microsoft/playwright-pytest) +- [Pretix Widget Documentation](https://docs.pretix.eu/guides/widget/) +- [Plan Document](/home/rash/.claude/plans/snazzy-wishing-horizon.md) - Complete test specification + +## Test Coverage + +Current test files cover Phase 1 (Critical Path): + +- ✅ Widget embedding & initialization (6 tests) +- ✅ Product variations (7 tests) +- ✅ Quantity controls & free price (11 tests) +- ✅ Cart management & checkout (7 tests) + +**Total: 31 tests implemented** + +See plan document for complete 130-test specification covering all widget features. diff --git a/src/tests/e2e/conftest.py b/src/tests/e2e/conftest.py new file mode 100644 index 0000000000..c53f200fb2 --- /dev/null +++ b/src/tests/e2e/conftest.py @@ -0,0 +1,1146 @@ +""" +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. +""" +import os +import pytest +from decimal import Decimal +from datetime import datetime, timezone, timedelta +from playwright.sync_api import Browser, BrowserContext, Page, expect +from django_scopes import scopes_disabled + +from pretix.base.models import ( + Organizer, Event, Item, Quota, ItemVariation, SubEvent, Voucher +) + +# Allow Django ORM operations in async context (required for Playwright integration) +os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") + + +# ============================================================================ +# 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": "America/New_York", + # 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', + } + + # Fix SITE_URL to point to live server instead of example.com. + # This makes build_absolute_uri() return localhost URLs so the widget's + # target_url (from the API response) resolves to the live server. + # Without this, form submissions (cart/add) go to example.com. + settings.SITE_URL = live_server.url + + return live_server.url + + +@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.views import View + from django.urls import path + 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): + # Build URLs exactly as in working index.html + base_url = f"{request.scheme}://{request.get_host()}" + event_url = f"{base_url}/{organizer}/{event}/" + # CSS is scoped to event, JS is at root + widget_css = f"{base_url}/{organizer}/{event}/widget/v2.css" + widget_js = f"{base_url}/widget/v2.en.js" + + # 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""" + + + + + Widget Test + + + + + + +""" + 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" + widget_js = f"{base_url}/widget/v2.en.js" + + # 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""" + + + + + Button Test + + + + {button_text} + + +""" + 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///', + WidgetTestView.as_view() + ) + button_pattern = path( + 'button-test///', + 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) + + +# ============================================================================ +# Test Data Fixtures - Organizers and Events +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_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 widget_event(widget_organizer): + """Create a basic event for widget tests.""" + event = Event.objects.create( + organizer=widget_organizer, + name='Test Event', + slug='testevent', + date_from=datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc), + date_to=datetime(2026, 6, 1, 18, 0, 0, tzinfo=timezone.utc), + currency='USD', + live=True, + testmode=False, + plugins='pretix.plugins.banktransfer', + ) + event.settings.set('timezone', 'America/New_York') + event.settings.set('locale', 'en') + event.settings.set('locales', ['en']) + return event + + +@pytest.fixture +@scopes_disabled() +def widget_items(widget_event): + """Create basic test items/products.""" + from pretix.base.models import ItemCategory + + items = [] + + # Create a proper category + category = ItemCategory.objects.create( + event=widget_event, + name='Tickets', + position=0 + ) + + # General Admission ticket + item1 = Item.objects.create( + event=widget_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=widget_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=widget_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 widget_item_with_variations(widget_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=widget_event, + name='Merchandise', + position=1 + ) + + item = Item.objects.create( + event=widget_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=widget_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 widget_item_single_select(widget_event): + """Create an item with max_per_order=1 (should show checkbox).""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='VIP', + position=2 + ) + + item = Item.objects.create( + event=widget_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=widget_event, + name='VIP Quota', + size=10, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_free_price(widget_event): + """Create an item with pay-what-you-want pricing.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Donations', + position=3 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Donation', + default_price=Decimal('10.00'), + description='Support our cause', + active=True, + free_price=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Donation Quota', + size=999, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_sold_out(widget_event): + """Create a sold out item.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Early Bird', + position=4 + ) + + item = Item.objects.create( + event=widget_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=widget_event, + name='Early Bird Quota', + size=0, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_free(widget_event): + """Create a free item (price = 0.00).""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Free Stuff', + position=10 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Free Gift', + default_price=Decimal('0.00'), + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Free Gift Quota', + size=100, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_with_decimals(widget_event): + """Create an item with non-zero decimal price.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Test Category', + position=11 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Half Price Item', + default_price=Decimal('12.50'), + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Half Price Quota', + size=50, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_with_tax(widget_event): + """Create an item with tax rule.""" + from pretix.base.models import ItemCategory, TaxRule + + # Create tax rule + tax_rule = TaxRule.objects.create( + event=widget_event, + name='VAT', + rate=Decimal('19.00'), # 19% VAT + ) + + category = ItemCategory.objects.create( + event=widget_event, + name='Taxed Items', + position=12 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Taxed Product', + default_price=Decimal('100.00'), + tax_rule=tax_rule, + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Taxed Product Quota', + size=50, + ) + quota.items.add(item) + + return item + + +# ============================================================================ +# Test Data Fixtures - Edge Cases +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_item_min_order(widget_event): + """Create an item with min_per_order=2.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Group Tickets', + position=13 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Group Pass', + default_price=Decimal('40.00'), + active=True, + min_per_order=2, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Group Pass Quota', + size=50, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_special_chars(widget_event): + """Create an item with special characters in the name.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Spezial', + position=14 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Böhm & Söhne Konzert', + default_price=Decimal('55.00'), + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Special Quota', + size=50, + ) + quota.items.add(item) + + return item + + +# ============================================================================ +# Test Data Fixtures - Categories +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_items_with_category_description(widget_event): + """Create items with a category that has a description.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Tickets', + description='Early bird tickets available', + position=0 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Early Bird', + default_price=Decimal('35.00'), + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Early Bird Quota', + size=100, + ) + quota.items.add(item) + + return [item] + + +@pytest.fixture +@scopes_disabled() +def widget_items_multiple_categories(widget_event): + """Create items in multiple categories to test grouping and ordering.""" + from pretix.base.models import ItemCategory + + cat_music = ItemCategory.objects.create( + event=widget_event, + name='Music', + position=0 + ) + + cat_food = ItemCategory.objects.create( + event=widget_event, + name='Food & Drink', + position=1 + ) + + item1 = Item.objects.create( + event=widget_event, + category=cat_music, + name='Concert Ticket', + default_price=Decimal('75.00'), + active=True, + ) + + item2 = Item.objects.create( + event=widget_event, + category=cat_food, + name='Food Pass', + default_price=Decimal('25.00'), + active=True, + ) + + for item in [item1, item2]: + quota = Quota.objects.create( + event=widget_event, + name=f'{item.name} Quota', + size=100, + ) + quota.items.add(item) + + return [item1, item2] + + +# ============================================================================ +# Test Data Fixtures - Vouchers +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_voucher(widget_event, widget_items): + """Create a voucher for the event.""" + voucher = Voucher.objects.create( + event=widget_event, + code='TESTCODE2024', + max_usages=10, + price_mode='none', + ) + # Clear the vouchers_exist cache so the widget picks it up + widget_event.get_cache().delete('vouchers_exist') + return voucher + + +@pytest.fixture +@scopes_disabled() +def widget_voucher_with_item(widget_event, widget_items): + """Create a voucher tied to a specific item.""" + item = widget_items[0] + voucher = Voucher.objects.create( + event=widget_event, + code='ITEMVOUCHER', + max_usages=5, + price_mode='percent', + value=Decimal('20.00'), # 20% off + item=item, + ) + widget_event.get_cache().delete('vouchers_exist') + return voucher + + +# ============================================================================ +# Test Data Fixtures - Waiting List +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_item_sold_out_with_waitinglist(widget_event): + """Create a sold out item with waiting list enabled.""" + from pretix.base.models import ItemCategory + + # Enable waiting list on the event + widget_event.settings.set('waiting_list_enabled', True) + + category = ItemCategory.objects.create( + event=widget_event, + name='Sold Out', + position=20 + ) + + item = Item.objects.create( + event=widget_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=widget_event, + name='Sold Out Quota', + size=0, + ) + quota.items.add(item) + + return item + + +# ============================================================================ +# Test Data Fixtures - Event Series +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_event_series(widget_organizer): + """Create an event series with multiple subevents, items, and quotas.""" + from pretix.base.models import ItemCategory + + event = Event.objects.create( + organizer=widget_organizer, + name='Concert Series', + slug='concert-series', + date_from=datetime(2026, 6, 1, 19, 0, 0, tzinfo=timezone.utc), + has_subevents=True, + currency='USD', + live=True, + plugins='pretix.plugins.banktransfer', + ) + event.settings.set('timezone', 'America/New_York') + event.settings.set('locale', 'en') + event.settings.set('locales', ['en']) + + # Create item + category for the series + 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, + ) + + # Create multiple subevents (dates) + subevents = [] + base_date = datetime(2026, 6, 1, 19, 0, 0, tzinfo=timezone.utc) + + for i in range(15): # Create 15 dates across 2 months + 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=3), + 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_widget_test_page( + self, + live_server_url: str, + org_slug: str, + event_slug: str, + **widget_attrs + ): + """ + Navigate to a test page with widget embedded. + + 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. + """ + # 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) + return self + + def goto_event(self, live_server_url: str, org_slug: str, event_slug: str): + """Navigate to an event page.""" + self.page.goto(f"{live_server_url}/{org_slug}/{event_slug}/") + return self + + def wait_for_widget_load(self): + """Wait for widget to finish loading.""" + # Wait for widget element with longer timeout + self.page.wait_for_selector('.pretix-widget', timeout=15000) + # Give it a moment to render content + self.page.wait_for_timeout(1000) + 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 + if number_input.count() > 0: + number_input.fill(str(quantity)) + 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)) + return self + + def click_buy_button(self): + """Click the buy/register button.""" + buy_button = self.page.locator('button:has-text("Buy"), 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.""" + close_btn = self.page.locator('.pretix-widget-frame-close button') + close_btn.click() + # Wait for iframe to close + self.page.wait_for_function( + """() => { + const frame = document.querySelector('.pretix-widget-frame-shown'); + return !frame || !frame.classList.contains('pretix-widget-frame-shown'); + }""", + timeout=5000 + ) + 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 widget_item_require_voucher(widget_event): + """Create an item that requires a voucher to purchase.""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Voucher Only', + position=30 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Exclusive Pass', + default_price=Decimal('200.00'), + active=True, + require_voucher=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Exclusive Quota', + size=50, + ) + quota.items.add(item) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_low_stock(widget_event): + """Create an item with low stock (quota_left visible).""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Limited', + position=31 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Last Chance Ticket', + default_price=Decimal('65.00'), + active=True, + ) + + quota = Quota.objects.create( + event=widget_event, + name='Limited Quota', + size=3, + ) + quota.items.add(item) + + # Enable "show quota left" on the event + widget_event.settings.set('show_quota_left', True) + + return item + + +@pytest.fixture +@scopes_disabled() +def widget_item_not_yet_available(widget_event): + """Create an item that is not yet available (future available_from).""" + from pretix.base.models import ItemCategory + + category = ItemCategory.objects.create( + event=widget_event, + name='Coming Soon', + position=32 + ) + + item = Item.objects.create( + event=widget_event, + category=category, + name='Future Ticket', + default_price=Decimal('45.00'), + active=True, + available_from=datetime(2099, 1, 1, tzinfo=timezone.utc), + available_from_mode='info', # Show as "not yet available" instead of hiding + ) + + quota = Quota.objects.create( + event=widget_event, + name='Future Quota', + size=100, + ) + quota.items.add(item) + + return item + + +# ============================================================================ +# Test Data Fixtures - Items with Pictures +# ============================================================================ + +@pytest.fixture +@scopes_disabled() +def widget_item_with_picture(widget_event): + """Create an item with a product picture.""" + from pretix.base.models import ItemCategory + from django.core.files.uploadedfile import SimpleUploadedFile + import io + from PIL import Image as PILImage + + category = ItemCategory.objects.create( + event=widget_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=widget_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=widget_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() diff --git a/src/tests/e2e/test_css_check.py b/src/tests/e2e/test_css_check.py new file mode 100644 index 0000000000..706add970d --- /dev/null +++ b/src/tests/e2e/test_css_check.py @@ -0,0 +1,63 @@ +""" +Test to verify CSS is properly compiled without SCSS syntax. +""" +import pytest +from playwright.sync_api import Page + + +@pytest.mark.django_db +def test_css_contains_no_scss_syntax( + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items +): + """Verify CSS is compiled and doesn't contain SCSS syntax.""" + # Fetch the CSS directly + css_url = f"{live_server_url}/{widget_organizer.slug}/{widget_event.slug}/widget/v2.css" + + print(f"\nFetching CSS from: {css_url}") + response = page.request.get(css_url) + + print(f"Status: {response.status}") + assert response.status == 200, f"CSS returned {response.status}" + + css_content = response.text() + + # Check that CSS doesn't contain SCSS syntax + scss_indicators = [ + '@include', # SCSS mixins + '@extend', # SCSS extends + '$', # SCSS variables (though $ can appear in selectors, check more carefully) + ] + + print(f"\nCSS length: {len(css_content)} characters") + print(f"\nFirst 500 chars of CSS:\n{css_content[:500]}") + + has_scss = False + for indicator in scss_indicators: + if indicator in css_content: + # For $, be more specific - check if it's actually a variable + if indicator == '$': + # Look for variable patterns like $variable-name or $variable_name + import re + if re.search(r'\$[a-zA-Z_]', css_content): + has_scss = True + print(f"\n⚠️ Found SCSS syntax: {indicator}") + # Find and print examples + matches = re.findall(r'\$[a-zA-Z_][a-zA-Z0-9_-]*', css_content) + print(f"Examples: {matches[:5]}") + else: + has_scss = True + print(f"\n⚠️ Found SCSS syntax: {indicator}") + # Find line with the indicator + for i, line in enumerate(css_content.split('\n')[:100]): + if indicator in line: + print(f"Line {i+1}: {line}") + break + + if not has_scss: + print("\n✅ CSS is properly compiled - no SCSS syntax found!") + + assert not has_scss, "CSS contains SCSS syntax - not properly compiled!" diff --git a/src/tests/e2e/test_views.py b/src/tests/e2e/test_views.py new file mode 100644 index 0000000000..b128af7c30 --- /dev/null +++ b/src/tests/e2e/test_views.py @@ -0,0 +1,50 @@ +""" +Test views for E2E widget testing. + +These views serve HTML pages with embedded widgets for testing purposes. +""" +from django.http import HttpResponse +from django.template import Template, Context +from django.views import View + + +class WidgetTestPageView(View): + """ + Serves a simple HTML page with the pretix widget embedded. + + Used for E2E testing to simulate how the widget would be embedded + in a customer's website. + """ + + def get(self, request, organizer, event): + """Render test page with widget embedded.""" + # Build event URL + event_url = request.build_absolute_uri( + f"/{organizer}/{event}/" + ) + + # Build widget script URL (old version) + widget_script_url = request.build_absolute_uri( + f"/widget/v2.en.js" + ) + + html_content = f""" + + + + + Widget Test Page + + +

Pretix Widget E2E Test Page

+ + + + + + + + +""" + + return HttpResponse(html_content, content_type="text/html") diff --git a/src/tests/e2e/test_widget_accessibility.py b/src/tests/e2e/test_widget_accessibility.py new file mode 100644 index 0000000000..efbaa487d9 --- /dev/null +++ b/src/tests/e2e/test_widget_accessibility.py @@ -0,0 +1,313 @@ +""" +E2E Tests for Accessibility + +Tests that verify: +- ARIA labels on main widget wrapper +- Heading roles and levels +- Voucher input labeling +- Buy button aria-describedby +- Keyboard navigation +- Quantity control labels +- Calendar table accessibility +- Variations toggle aria-expanded/aria-controls +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestWidgetAriaLabels: + """Test ARIA attributes on the widget structure.""" + + def test_widget_wrapper_has_role_article( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Main widget wrapper should have role="article" + and aria-label with event name. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + wrapper = page.locator('.pretix-widget-wrapper') + expect(wrapper).to_have_attribute('role', 'article') + expect(wrapper).to_have_attribute('aria-label', widget_event.name) + + def test_widget_wrapper_is_focusable( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget wrapper should have tabindex="0" for keyboard access. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + wrapper = page.locator('.pretix-widget-wrapper') + expect(wrapper).to_have_attribute('tabindex', '0') + + def test_event_name_has_heading_role( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Event name heading in event form should have role="heading" + with aria-level="2". + + Note: Event header is only shown when display_event_info + is explicitly enabled for single events (auto mode hides it). + We use the display-event-info attribute to force it on. + """ + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + **{'display-event-info': 'true'} + ) + widget_page.wait_for_widget_load() + + heading = page.locator( + '.pretix-widget-event-header strong[role="heading"]') + expect(heading).to_be_visible() + expect(heading).to_have_attribute('aria-level', '2') + + +@pytest.mark.django_db +class TestQuantityControlAccessibility: + """Test accessibility of quantity controls.""" + + def test_increment_button_has_aria_label( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Plus/minus buttons should have descriptive aria-labels. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Find increment button for first item + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_items[0].name}")') + inc_btn = item_elem.locator('button[aria-label]').last + dec_btn = item_elem.locator('button[aria-label]').first + + # Should have aria-labels + inc_label = inc_btn.get_attribute('aria-label') + dec_label = dec_btn.get_attribute('aria-label') + assert inc_label is not None and len(inc_label) > 0 + assert dec_label is not None and len(dec_label) > 0 + + def test_quantity_input_has_aria_labelledby( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Quantity input should be connected to a label via aria-labelledby. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_items[0].name}")') + qty_input = item_elem.locator('input[type="number"]') + + labelledby = qty_input.get_attribute('aria-labelledby') + assert labelledby is not None and len(labelledby) > 0 + + +@pytest.mark.django_db +class TestVoucherAccessibility: + """Test accessibility of voucher input.""" + + def test_voucher_input_has_aria_labelledby( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_voucher, + widget_page + ): + """ + Voucher input should reference the headline via aria-labelledby. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + voucher_input = page.locator('.pretix-widget-voucher-input') + headline = page.locator('.pretix-widget-voucher-headline') + + # Headline should have an ID + headline_id = headline.get_attribute('id') + assert headline_id is not None + + # Input should reference it + labelledby = voucher_input.get_attribute('aria-labelledby') + assert labelledby == headline_id + + +@pytest.mark.django_db +class TestVariationAccessibility: + """Test accessibility of variation toggles.""" + + def test_variations_toggle_has_aria_expanded( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Variations toggle button should have aria-expanded + and aria-controls attributes. + """ + item, _ = widget_item_with_variations + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + toggle_btn = item_elem.locator( + 'button[aria-expanded]') + + # Should start collapsed + expect(toggle_btn).to_have_attribute('aria-expanded', 'false') + + # Should reference the variations container + controls = toggle_btn.get_attribute('aria-controls') + assert controls is not None + + # Click to expand + toggle_btn.click() + expect(toggle_btn).to_have_attribute('aria-expanded', 'true') + + +@pytest.mark.django_db +class TestCalendarAccessibility: + """Test accessibility of calendar view.""" + + def test_calendar_table_is_focusable( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + Calendar table should have tabindex="0" and aria-labelledby. + """ + event, _ = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'calendar'} + ) + widget_page.wait_for_widget_load() + + table = page.locator('.pretix-widget-event-calendar-table') + expect(table).to_have_attribute('tabindex', '0') + + # Should be labeled by the month heading + labelledby = table.get_attribute('aria-labelledby') + assert labelledby is not None + + def test_calendar_day_headers_have_aria_labels( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + Calendar day-of-week headers should have full day names + as aria-labels (Mo -> Monday, Tu -> Tuesday, etc.). + """ + event, _ = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'calendar'} + ) + widget_page.wait_for_widget_load() + + # Check first day header has aria-label + first_header = page.locator( + '.pretix-widget-event-calendar-table thead th').first + label = first_header.get_attribute('aria-label') + assert label is not None + # Should be a full day name like "Monday" + assert len(label) > 2 + + +@pytest.mark.django_db +class TestKeyboardNavigation: + """Test keyboard navigation through the widget.""" + + def test_tab_reaches_interactive_elements( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Pressing Tab should cycle through interactive elements + (inputs, buttons) within the widget. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Tab through several elements + focused_tags = set() + for _ in range(10): + page.keyboard.press('Tab') + tag = page.evaluate('() => document.activeElement.tagName') + focused_tags.add(tag) + + # Should have reached at least inputs and buttons + assert 'INPUT' in focused_tags or 'BUTTON' in focused_tags diff --git a/src/tests/e2e/test_widget_availability.py b/src/tests/e2e/test_widget_availability.py new file mode 100644 index 0000000000..ca97d285c2 --- /dev/null +++ b/src/tests/e2e/test_widget_availability.py @@ -0,0 +1,210 @@ +""" +E2E Tests for Availability States + +Tests that verify: +- Sold out items show "Sold out" message +- Low stock items show "currently available: N" +- Require-voucher items show voucher message +- Not-yet-available items show "Not yet available" +- Available items show quantity selector +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestSoldOutState: + """Test sold out availability display.""" + + def test_sold_out_shows_message( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out, + widget_page + ): + """Sold out item should show 'Sold out' text.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_sold_out.name}")') + expect(item_elem).to_be_visible() + + # Should show "Sold out" in the availability area + avail = item_elem.locator('.pretix-widget-availability-gone') + expect(avail).to_be_visible() + + def test_sold_out_has_no_quantity_selector( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out, + widget_page + ): + """Sold out item should not show any input controls.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_sold_out.name}")') + expect(item_elem).to_be_visible() + + # Should not have any input controls + assert item_elem.locator('input').count() == 0 + + +@pytest.mark.django_db +class TestQuotaLeftDisplay: + """Test quota-left indicator for low stock items.""" + + def test_low_stock_shows_currently_available( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_low_stock, + widget_page + ): + """ + Item with low stock and show_quota_left should display + the number of remaining tickets. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_low_stock.name}")') + expect(item_elem).to_be_visible() + + # Should show quota left text containing "3" + # The widget uses "currently available: 3" format + expect(item_elem).to_contain_text('3') + + def test_low_stock_still_has_quantity_selector( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_low_stock, + widget_page + ): + """Low stock items should still be purchasable.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_low_stock.name}")') + expect(item_elem).to_be_visible() + + # Should have quantity input + expect(item_elem.locator('input')).to_be_visible() + + +@pytest.mark.django_db +class TestRequireVoucherState: + """Test require-voucher unavailability message.""" + + def test_require_voucher_shows_message( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_require_voucher, + widget_page + ): + """Item requiring voucher should show 'Only available with a voucher'.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_require_voucher.name}")') + expect(item_elem).to_be_visible() + + # Should show unavailability message with voucher link + unavail = item_elem.locator('.pretix-widget-availability-unavailable') + expect(unavail).to_be_visible() + expect(unavail).to_contain_text('voucher') + + def test_require_voucher_has_link_to_voucher_input( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_require_voucher, + widget_voucher, + widget_page + ): + """Voucher-required message should link to the voucher input field.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_require_voucher.name}")') + unavail = item_elem.locator('.pretix-widget-availability-unavailable') + + # Should have a link (to jump to voucher input) + link = unavail.locator('a') + expect(link).to_be_visible() + + +@pytest.mark.django_db +class TestNotYetAvailable: + """Test not-yet-available state.""" + + def test_future_item_shows_not_yet_available( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_not_yet_available, + widget_page + ): + """Item with future available_from should show 'Not yet available'.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_not_yet_available.name}")') + expect(item_elem).to_be_visible() + + # Should show unavailability message + unavail = item_elem.locator('.pretix-widget-availability-unavailable') + expect(unavail).to_be_visible() + + def test_future_item_has_no_quantity_selector( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_not_yet_available, + widget_page + ): + """Not-yet-available items should not have quantity controls.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_not_yet_available.name}")') + expect(item_elem).to_be_visible() + + # Should not have any input controls + assert item_elem.locator('input').count() == 0 diff --git a/src/tests/e2e/test_widget_cart.py b/src/tests/e2e/test_widget_cart.py new file mode 100644 index 0000000000..145ed3afc5 --- /dev/null +++ b/src/tests/e2e/test_widget_cart.py @@ -0,0 +1,302 @@ +""" +E2E Tests for Cart Management & Checkout Flow + +Tests that verify: +- Adding items to cart opens iframe checkout +- Empty cart does not open checkout +- Cart persistence via cookies +- Resume checkout after page reload +- Multiple item selection +- Mixed input types (checkbox + quantity) +""" +import pytest +from playwright.sync_api import Page, expect, BrowserContext + + +@pytest.mark.django_db +class TestCartBasics: + """Test basic cart functionality.""" + + def test_add_to_cart_opens_iframe_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Selecting items and clicking Buy should open iframe checkout.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 2) + widget_page.click_buy_button() + + # Iframe checkout should open + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'iframe=1' in src + assert 'take_cart_id' in src + + # page.pause() + + def test_empty_cart_does_not_open_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Clicking Buy without selecting items should not open checkout.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Don't select any items, just click buy + widget_page.click_buy_button() + page.wait_for_timeout(1000) + + # No iframe should have opened + expect(page.locator('.pretix-widget-frame-shown')).not_to_be_visible() + + # Widget should still be visible (didn't navigate away) + expect(page.locator('.pretix-widget')).to_be_visible() + + # Items should still be there + expect(page.locator( + f'.pretix-widget-item:has-text("{widget_items[0].name}")' + )).to_be_visible() + + +@pytest.mark.django_db +class TestCartPersistence: + """Test cart persistence across page reloads.""" + + def test_cart_cookie_set_after_checkout( + self, + page: Page, + context: BrowserContext, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Adding items to cart should create a pretix_widget cookie.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 1) + widget_page.click_buy_button() + + # Wait for iframe checkout to open (cookie is set during cart creation) + widget_page.wait_for_iframe_checkout() + page.wait_for_timeout(2000) + + cookies = context.cookies() + widget_cookies = [c for c in cookies if c['name'].startswith('pretix_widget_')] + assert len(widget_cookies) > 0, ( + f"Expected pretix_widget cookie after checkout, got: {[c['name'] for c in cookies]}" + ) + + def test_resume_checkout_after_reload( + self, + page: Page, + context: BrowserContext, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """After creating a cart and reloading, widget should show resume option.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Create a real cart by adding items and opening checkout + widget_page.select_item_quantity(widget_items[0].name, 1) + widget_page.click_buy_button() + widget_page.wait_for_iframe_checkout() + + # Wait for cart cookie to be set (set in buy_callback when cart is created) + page.wait_for_function( + "() => document.cookie.includes('pretix_widget_')", + timeout=10000 + ) + + # Close iframe and reload + widget_page.close_iframe() + page.reload() + widget_page.wait_for_widget_load() + + # Should show "Resume checkout" button (class: pretix-widget-resume-button) + resume_btn = page.locator('.pretix-widget-resume-button') + expect(resume_btn).to_be_visible(timeout=5000) + + +@pytest.mark.django_db +class TestIframeCheckout: + """Test iframe checkout flow (enabled via skip-ssl-check + SITE_URL fix).""" + + def test_iframe_checkout_opens( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """With skip-ssl-check, checkout should open in iframe on HTTP.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 1) + widget_page.click_buy_button() + + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'iframe=1' in src + assert widget_organizer.slug in src + + def test_close_iframe_button( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """User should be able to close checkout iframe.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 1) + widget_page.click_buy_button() + + widget_page.wait_for_iframe_checkout() + page.wait_for_timeout(3000) + + widget_page.close_iframe() + + expect(page.locator('.pretix-widget-frame-shown')).not_to_be_visible() + + def test_iframe_checkout_has_take_cart_id( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Iframe checkout URL should include take_cart_id parameter.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 2) + widget_page.click_buy_button() + + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'take_cart_id' in src + + def test_overlay_visible_during_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Iframe checkout overlay container should be visible during checkout.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 1) + widget_page.click_buy_button() + + widget_page.wait_for_iframe_checkout() + + overlay = page.locator('.pretix-widget-frame-holder') + expect(overlay).to_be_visible() + + +@pytest.mark.django_db +class TestMultipleItemSelection: + """Test selecting and submitting multiple items.""" + + def test_select_multiple_different_items( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Selecting multiple items should open checkout with all of them.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Select multiple items + widget_page.select_item_quantity(widget_items[0].name, 2) + widget_page.select_item_quantity(widget_items[1].name, 1) + + widget_page.click_buy_button() + + # Iframe should open with both items in the cart + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'take_cart_id' in src + + def test_mixed_checkbox_and_quantity_items( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_item_single_select, + widget_page + ): + """Selecting both checkbox and quantity items should open checkout.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Select quantity item + widget_page.select_item_quantity(widget_items[0].name, 3) + + # Select checkbox item + widget_page.select_item_quantity(widget_item_single_select.name, 1) + + widget_page.click_buy_button() + + # Iframe should open + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'take_cart_id' in src diff --git a/src/tests/e2e/test_widget_categories.py b/src/tests/e2e/test_widget_categories.py new file mode 100644 index 0000000000..11593dd7b1 --- /dev/null +++ b/src/tests/e2e/test_widget_categories.py @@ -0,0 +1,107 @@ +""" +E2E Tests for Categories & Organization + +Tests that verify: +- Category headers display correctly +- Category descriptions render +- Items are grouped under their respective categories +- Category sort order is maintained +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestCategoryDisplay: + """Test category header and description rendering.""" + + def test_category_headers_display( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Category names should be shown as h3 headers. + + The widget_items fixture creates a 'Tickets' category. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Category header should be visible + category_header = page.locator( + '.pretix-widget-category-name:text-is("Tickets")') + expect(category_header).to_be_visible() + + def test_category_description_renders( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items_with_category_description, + widget_page + ): + """ + Category descriptions should be displayed below category name. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Category description should be visible + desc = page.locator('.pretix-widget-category-description') + expect(desc.first).to_be_visible() + expect(desc.first).to_contain_text('Early bird tickets available') + + def test_items_grouped_by_category( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items_multiple_categories, + widget_page + ): + """ + Items should be grouped under respective categories + and maintain category sort order. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Both categories should be visible + expect(page.locator( + '.pretix-widget-category-name:text-is("Music")' + )).to_be_visible() + expect(page.locator( + '.pretix-widget-category-name:text-is("Food & Drink")' + )).to_be_visible() + + # Concert Ticket should be under "Music" category + music_cat = page.locator( + '.pretix-widget-category:has(.pretix-widget-category-name' + ':text-is("Music"))') + expect(music_cat.locator( + '.pretix-widget-item:has-text("Concert Ticket")' + )).to_be_visible() + + # Food Pass should be under "Food & Drink" category + food_cat = page.locator( + '.pretix-widget-category:has(.pretix-widget-category-name' + ':text-is("Food & Drink"))') + expect(food_cat.locator( + '.pretix-widget-item:has-text("Food Pass")' + )).to_be_visible() + + # Verify ordering: Music (position=0) should come before + # Food & Drink (position=1) + categories = page.locator('.pretix-widget-category-name') + first_cat = categories.nth(0) + expect(first_cat).to_have_text('Music') diff --git a/src/tests/e2e/test_widget_config.py b/src/tests/e2e/test_widget_config.py new file mode 100644 index 0000000000..2b2b18a64e --- /dev/null +++ b/src/tests/e2e/test_widget_config.py @@ -0,0 +1,187 @@ +""" +E2E Tests for Widget Configuration Attributes + +Tests that verify: +- items attribute filters to specific products +- categories attribute filters by category +- disable-vouchers hides voucher input +- disable-iframe forces new tab checkout +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestItemsFilter: + """Test items attribute for filtering products.""" + + def test_items_attribute_shows_only_specified_items( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + When items="" is set, only that item should be shown. + """ + # Get the first item's ID + target_item = widget_items[0] + other_item = widget_items[1] + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + items=str(target_item.pk) + ) + widget_page.wait_for_widget_load() + + # Target item should be visible + expect(page.locator( + f'.pretix-widget-item:has-text("{target_item.name}")' + )).to_be_visible() + + # Other item should NOT be visible + expect(page.locator( + f'.pretix-widget-item:has-text("{other_item.name}")' + )).to_have_count(0) + + def test_items_attribute_with_multiple_ids( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + When items=",", both items should be shown. + """ + ids = ','.join(str(item.pk) for item in widget_items) + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + items=ids + ) + widget_page.wait_for_widget_load() + + # Both items should be visible + for item in widget_items: + expect(page.locator( + f'.pretix-widget-item:has-text("{item.name}")' + )).to_be_visible() + + +@pytest.mark.django_db +class TestCategoriesFilter: + """Test categories attribute for filtering by category.""" + + def test_categories_attribute_shows_only_specified_category( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items_multiple_categories, + widget_page + ): + """ + When categories="" is set, only items from that category + should be shown. + """ + items = widget_items_multiple_categories + # Get the category of the first item (Music) + target_category = items[0].category + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + categories=str(target_category.pk) + ) + widget_page.wait_for_widget_load() + + # Music item should be visible + expect(page.locator( + f'.pretix-widget-item:has-text("{items[0].name}")' + )).to_be_visible() + + # Food item should NOT be visible + expect(page.locator( + f'.pretix-widget-item:has-text("{items[1].name}")' + )).to_have_count(0) + + +@pytest.mark.django_db +class TestDisableVouchers: + """Test disable-vouchers attribute.""" + + def test_disable_vouchers_hides_voucher_input( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_voucher, + widget_page + ): + """ + When disable-vouchers is set, voucher input should be hidden + even when vouchers exist. + """ + # Navigate with disable-vouchers attribute + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + **{'disable-vouchers': ''} + ) + widget_page.wait_for_widget_load() + + # Voucher section should NOT be visible + voucher_section = page.locator('.pretix-widget-voucher') + expect(voucher_section).to_have_count(0) + + +@pytest.mark.django_db +class TestDisableIframe: + """Test disable-iframe attribute.""" + + def test_disable_iframe_opens_new_tab( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + When disable-iframe is set, checkout should open in a new tab + instead of an iframe overlay. + """ + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug, + **{'disable-iframe': ''} + ) + widget_page.wait_for_widget_load() + + # Select quantity for an item + widget_page.select_item_quantity('General Admission', 1) + + # Clicking buy should open a new tab (popup), not an iframe + with page.expect_popup() as popup_info: + widget_page.click_buy_button() + + popup = popup_info.value + # New tab should navigate to checkout URL + assert widget_organizer.slug in popup.url + popup.close() diff --git a/src/tests/e2e/test_widget_display_modes.py b/src/tests/e2e/test_widget_display_modes.py new file mode 100644 index 0000000000..0b48505689 --- /dev/null +++ b/src/tests/e2e/test_widget_display_modes.py @@ -0,0 +1,317 @@ +""" +E2E Tests for Widget Display Modes + +Tests that verify: +- Default widget mode shows full ticket shop +- Calendar view for event series +- List view for event series +- Button mode opens checkout directly +- Calendar navigation (next/prev month) +- Clicking a date in calendar navigates to event +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestWidgetMode: + """Test default widget mode (full ticket shop).""" + + def test_widget_mode_shows_items_and_buy_button( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Default widget mode should show full product listing + with categories, items, and a buy button. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should show items + for item in widget_items: + expect(page.locator( + f'.pretix-widget-item:has-text("{item.name}")' + )).to_be_visible() + + # Should show buy/add-to-cart button + expect(page.locator( + 'button:has-text("Add to cart"), button:has-text("Buy")' + ).first).to_be_visible() + + +@pytest.mark.django_db +class TestCalendarView: + """Test calendar display mode for event series.""" + + def test_calendar_view_displays_month_grid( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + Calendar view should show a monthly grid with event dates. + """ + event, subevents = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'calendar'} + ) + widget_page.wait_for_widget_load() + + # Should show calendar table + expect(page.locator( + '.pretix-widget-event-calendar-table' + )).to_be_visible() + + # Should have day cells with events + event_cells = page.locator('.pretix-widget-has-events') + expect(event_cells.first).to_be_visible() + + def test_calendar_view_navigation_next_month( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + Clicking next month button should navigate to the next month. + """ + event, subevents = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'calendar'} + ) + widget_page.wait_for_widget_load() + + # Get current month heading text + header = page.locator('.pretix-widget-event-calendar-head') + initial_text = header.inner_text() + + # Click next month button + next_btn = page.locator( + '.pretix-widget-event-calendar-head a').last + next_btn.click() + + # Wait for calendar to update + page.wait_for_timeout(1000) + + # Month heading should change + updated_text = header.inner_text() + assert updated_text != initial_text + + def test_calendar_event_links_are_clickable( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + Calendar event entries should be clickable links that show + event name, time, and availability status. + + Note: Full subevent navigation requires domain configuration + (target_url resolves to configured domain, not live_server). + We verify the links exist and have correct structure. + """ + event, _ = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'calendar'} + ) + widget_page.wait_for_widget_load() + + # Event links should exist and show event info + event_link = page.locator( + '.pretix-widget-event-calendar-event').first + expect(event_link).to_be_visible() + + # Should show event name + expect(event_link.locator( + '.pretix-widget-event-calendar-event-name' + )).to_be_visible() + + # Should show time range + expect(event_link.locator( + '.pretix-widget-event-calendar-event-date' + )).to_be_visible() + + # Should show availability ("Buy now" for available events) + expect(event_link.locator( + '.pretix-widget-event-calendar-event-availability' + )).to_be_visible() + + +@pytest.mark.django_db +class TestListView: + """Test list display mode for event series.""" + + def test_list_view_displays_events( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + List view should display events as a linear list. + """ + event, subevents = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'list'} + ) + widget_page.wait_for_widget_load() + + # Should show event list entries + entries = page.locator('.pretix-widget-event-list-entry') + expect(entries.first).to_be_visible() + + # Should have multiple entries + assert entries.count() > 0 + + def test_list_view_shows_event_names( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + List view should show subevent names. + """ + event, subevents = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'list'} + ) + widget_page.wait_for_widget_load() + + # At least the first subevent name should appear + expect(page.locator( + f':text-is("{subevents[0].name}")' + )).to_be_visible() + + def test_list_view_entries_show_availability( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event_series, + widget_page + ): + """ + List view entries should show availability status. + + Note: Full subevent navigation requires domain configuration + (target_url resolves to configured domain, not live_server). + We verify the entries have correct structure. + """ + event, _ = widget_event_series + + widget_page.goto_widget_test_page( + live_server_url, + widget_organizer.slug, + event.slug, + **{'list-type': 'list'} + ) + widget_page.wait_for_widget_load() + + # Each entry should show availability info + first_entry = page.locator( + '.pretix-widget-event-list-entry').first + expect(first_entry).to_be_visible() + + # Should show availability indicator (green = available) + availability = first_entry.locator( + '.pretix-widget-event-list-entry-availability') + expect(availability).to_be_visible() + + +@pytest.mark.django_db +class TestButtonMode: + """Test button display mode.""" + + def test_button_mode_shows_button( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Button mode should show a simple button. + """ + widget_page.goto_button_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug + ) + + # Wait for script to load and initialize + page.wait_for_timeout(2000) + + # Should show the button + button = page.locator('.pretix-button') + expect(button).to_be_visible() + expect(button).to_have_text('Buy tickets!') + + def test_button_click_opens_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Clicking the button should open checkout (new tab on HTTP). + """ + widget_page.goto_button_test_page( + live_server_url, + widget_organizer.slug, + widget_event.slug + ) + + page.wait_for_timeout(2000) + + # Click button - on HTTP it opens in new tab + with page.expect_popup() as popup_info: + page.locator('.pretix-button').click() + + popup = popup_info.value + assert widget_organizer.slug in popup.url + popup.close() diff --git a/src/tests/e2e/test_widget_edge_cases.py b/src/tests/e2e/test_widget_edge_cases.py new file mode 100644 index 0000000000..74a35d3573 --- /dev/null +++ b/src/tests/e2e/test_widget_edge_cases.py @@ -0,0 +1,131 @@ +""" +E2E Tests for Edge Cases + +Tests that verify: +- Empty event (no items) displays gracefully +- Item with min_per_order enforces minimum +- Zero quantity submission shows warning +- Widget handles special characters in names +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestEmptyStates: + """Test widget behavior with empty or minimal data.""" + + def test_event_with_no_items_shows_empty( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Event with no items should still load without errors. + Should show the widget container but no item rows. + """ + # Navigate without creating any items + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Widget should still be present + expect(page.locator('.pretix-widget')).to_be_visible() + + # No items should be shown + items = page.locator('.pretix-widget-item') + assert items.count() == 0 + + def test_zero_quantity_stays_on_widget( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Submitting with zero quantity should not navigate away. + The widget should remain visible without opening checkout. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Don't select any items, just click buy + widget_page.click_buy_button() + page.wait_for_timeout(1000) + + # Widget should still be on the same page (no checkout opened) + expect(page.locator('.pretix-widget')).to_be_visible() + + # Items should still be visible + expect(page.locator( + f'.pretix-widget-item:has-text("{widget_items[0].name}")' + )).to_be_visible() + + +@pytest.mark.django_db +class TestMinPerOrder: + """Test minimum order quantity enforcement.""" + + def test_item_with_min_per_order_shows_message( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_min_order, + widget_page + ): + """ + Items with min_per_order should display a text message + indicating the minimum quantity (e.g. "minimum amount to order: 2"). + """ + item = widget_item_min_order + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should show minimum order message containing "2" + meta = item_elem.locator('.pretix-widget-item-meta') + expect(meta).to_be_visible() + expect(meta).to_contain_text('2') + + +@pytest.mark.django_db +class TestSpecialCharacters: + """Test widget handles special characters correctly.""" + + def test_item_name_with_special_characters( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_special_chars, + widget_page + ): + """ + Items with special characters (umlauts, ampersands, etc.) + should display correctly. + """ + item = widget_item_special_chars + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Item with special characters should be visible + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() diff --git a/src/tests/e2e/test_widget_embedding.py b/src/tests/e2e/test_widget_embedding.py new file mode 100644 index 0000000000..edd015c4a2 --- /dev/null +++ b/src/tests/e2e/test_widget_embedding.py @@ -0,0 +1,194 @@ +""" +E2E Tests for Widget Embedding & Initialization + +Tests that verify the pretix widget loads correctly, initializes properly, +and displays basic event information. +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestWidgetEmbedding: + """Test basic widget embedding and initialization.""" + + def test_widget_loads_successfully( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should load and display event information. + + Verifies that the widget loads on the page and shows: + - Event name + - All configured items + """ + # Navigate to test page with widget embedded + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug + ) + widget_page.wait_for_widget_load() + + # Verify widget container exists with event aria-label + widget = page.locator('.pretix-widget-wrapper') + expect(widget).to_have_attribute('aria-label', widget_event.name) + + # Verify items are listed + for item in widget_items: + expect(page.locator(f'text="{item.name}"')).to_be_visible() + + def test_widget_displays_loading_state( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Widget should show loading spinner during initial load. + + The loading spinner should eventually disappear when data is loaded. + """ + # Navigate to widget test page + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug + ) + + # Wait for widget element + page.wait_for_selector('.pretix-widget', timeout=10000) + + # Loading spinner should eventually be hidden + loading = page.locator('.pretix-widget-loading') + expect(loading).to_be_hidden(timeout=10000) + + def test_widget_handles_invalid_event( + self, + page: Page, + live_server_url: str, + widget_page + ): + """Widget should display error message for invalid event.""" + widget_page.goto_widget_test_page( + live_server_url, 'invalid-org', 'invalid-event' + ) + + # Should show widget container + page.wait_for_selector('.pretix-widget', timeout=10000) + + # Should show error message + error_msg = page.locator('.pretix-widget-error-message') + expect(error_msg).to_be_visible(timeout=10000) + expect(error_msg).to_contain_text('could not be loaded') + + def test_widget_shows_item_descriptions( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should display item descriptions. + + Item descriptions should be visible for items that have them. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug + ) + widget_page.wait_for_widget_load() + + # Check that descriptions are shown + for item in widget_items: + if item.description: + expect( + page.locator(f'text="{item.description}"') + ).to_be_visible() + + def test_widget_shows_item_prices( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should display item prices correctly. + + Prices should be formatted with currency and decimal places. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug + ) + widget_page.wait_for_widget_load() + + # Verify prices are shown (with currency) + # Each item should have its price displayed + for item in widget_items: + # Find the item container first, then check price within it + item_container = page.locator( + f'.pretix-widget-item:has-text("{item.name}")' + ) + expect(item_container).to_be_visible() + + # Check price is present (formatted as "USD XX.XX") + price_text = f"{float(item.default_price):.2f}" + price_box = item_container.locator('.pretix-widget-pricebox') + expect(price_box).to_contain_text(price_text) + + +@pytest.mark.django_db +class TestWidgetEventInfo: + """Test event information display in widget.""" + + def test_widget_displays_event_date( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Widget should show event date and time. + + Note: The old widget.js implementation may not display event + date by default. This test verifies the widget loads without + checking for specific date display. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug + ) + widget_page.wait_for_widget_load() + + # Widget should be present and functional + # (Event date display varies by configuration) + widget = page.locator('.pretix-widget') + expect(widget).to_be_visible() + + def test_widget_hides_event_info_when_configured( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Widget should hide event info when display-event-info="false". + + This test would require creating a widget embed page with the attribute. + Currently just a placeholder for future implementation. + """ + # TODO: This requires creating a custom HTML page with widget embed + # For now, skip this test + pytest.skip("Requires custom widget embed page with attributes") diff --git a/src/tests/e2e/test_widget_errors.py b/src/tests/e2e/test_widget_errors.py new file mode 100644 index 0000000000..96ab6f72c3 --- /dev/null +++ b/src/tests/e2e/test_widget_errors.py @@ -0,0 +1,120 @@ +""" +E2E Tests for Error Handling + +Tests that verify: +- Error message on invalid event +- Error message shows "Open ticket shop" link +- Sold out items show unavailable state +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestErrorDisplay: + """Test error messages and states.""" + + def test_invalid_event_shows_error_message( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Loading a non-existent event should show an error message. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, 'nonexistent-event') + widget_page.wait_for_widget_load() + + # Should show error message + expect(page.locator( + '.pretix-widget-error-message' + )).to_be_visible() + + def test_error_shows_open_in_new_tab_link( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_page + ): + """ + Error state should include a link to open the ticket shop + in a new tab as a fallback. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, 'nonexistent-event') + widget_page.wait_for_widget_load() + + # Should show fallback action link + action_link = page.locator('.pretix-widget-error-action a') + expect(action_link).to_be_visible() + + # Link should open in new tab + expect(action_link).to_have_attribute('target', '_blank') + + +@pytest.mark.django_db +class TestSoldOutState: + """Test sold out item display.""" + + def test_sold_out_item_shows_unavailable( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out, + widget_page + ): + """ + Items with zero quota should show as unavailable/sold out. + """ + item = widget_item_sold_out + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should not have a quantity input or checkbox (sold out) + input_count = item_elem.locator( + 'input[type="number"], input[type="checkbox"]').count() + assert input_count == 0 + + def test_sold_out_item_shows_status_text( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out, + widget_page + ): + """ + Sold out items should show a status message like + "Sold out" or "Currently unavailable". + """ + item = widget_item_sold_out + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + + # Should show some unavailability text + avail_col = item_elem.locator( + '.pretix-widget-item-availability-col') + expect(avail_col).to_be_visible() + # The text could be "Sold out", "Currently unavailable", etc. + avail_text = avail_col.inner_text() + assert len(avail_text.strip()) > 0 diff --git a/src/tests/e2e/test_widget_lightbox.py b/src/tests/e2e/test_widget_lightbox.py new file mode 100644 index 0000000000..66fb8f97ca --- /dev/null +++ b/src/tests/e2e/test_widget_lightbox.py @@ -0,0 +1,208 @@ +""" +E2E Tests for Item Images & Lightbox + +Tests that verify: +- Item with picture shows thumbnail +- Clicking thumbnail opens lightbox overlay +- Lightbox close button works +- Lightbox has correct ARIA structure +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestItemPicture: + """Test item picture thumbnail display.""" + + def test_item_with_picture_shows_thumbnail( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Item with picture should display a thumbnail image.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + expect(item_elem).to_be_visible() + + # Should have picture element + picture = item_elem.locator('.pretix-widget-item-picture') + expect(picture).to_be_visible() + + def test_item_with_picture_has_alt_text( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Item picture should have alt text.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + + img = item_elem.locator('.pretix-widget-item-picture') + alt = img.get_attribute('alt') + assert alt is not None and len(alt) > 0 + + def test_item_with_picture_has_link( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Picture should be wrapped in a clickable link.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + + link = item_elem.locator('.pretix-widget-item-picture-link') + expect(link).to_be_visible() + href = link.get_attribute('href') + assert href is not None and len(href) > 0 + + def test_item_has_picture_class( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Item row should have pretix-widget-item-with-picture class.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item-with-picture:has-text("{widget_item_with_picture.name}")') + expect(item_elem).to_be_visible() + + +@pytest.mark.django_db +class TestLightbox: + """Test lightbox overlay for item pictures.""" + + def test_click_picture_opens_lightbox( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Clicking item picture should open lightbox overlay.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + + # Click the picture link + link = item_elem.locator('.pretix-widget-item-picture-link') + link.click() + + # Lightbox should appear + lightbox = page.locator('.pretix-widget-lightbox-shown') + expect(lightbox).to_be_visible() + + def test_lightbox_shows_fullsize_image( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Lightbox should display fullsize image.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + item_elem.locator('.pretix-widget-item-picture-link').click() + + # Wait for lightbox + page.wait_for_timeout(1000) + + # Should have an image inside the lightbox + lightbox_img = page.locator('.pretix-widget-lightbox-image img') + expect(lightbox_img).to_be_visible() + + def test_lightbox_close_button( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Lightbox close button should close the overlay.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + item_elem.locator('.pretix-widget-item-picture-link').click() + + # Wait for lightbox to appear + lightbox = page.locator('.pretix-widget-lightbox-shown') + expect(lightbox).to_be_visible() + + # Click close button + close_btn = page.locator('.pretix-widget-lightbox-close button') + close_btn.click() + + # Lightbox should close + page.wait_for_timeout(500) + expect(page.locator('.pretix-widget-lightbox-shown')).not_to_be_visible() + + def test_lightbox_has_alertdialog_role( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_picture, + widget_page + ): + """Lightbox dialog should have role='alertdialog'.""" + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{widget_item_with_picture.name}")') + item_elem.locator('.pretix-widget-item-picture-link').click() + + page.wait_for_timeout(1000) + + dialog = page.locator('.pretix-widget-lightbox-holder') + role = dialog.get_attribute('role') + assert role == 'alertdialog' diff --git a/src/tests/e2e/test_widget_loading.py b/src/tests/e2e/test_widget_loading.py new file mode 100644 index 0000000000..5114c63eff --- /dev/null +++ b/src/tests/e2e/test_widget_loading.py @@ -0,0 +1,111 @@ +""" +E2E Tests for Loading States & Performance + +Tests that verify: +- Loading spinner appears during widget initialization +- Loading spinner disappears after content loads +- Widget loads within acceptable time +- No JavaScript errors during widget initialization +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestLoadingStates: + """Test loading states and transitions.""" + + def test_loading_spinner_disappears_after_load( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Loading spinner should be hidden once widget content loads. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Loading spinner should be hidden (display:none) + loading = page.locator('.pretix-widget-loading') + expect(loading).to_be_hidden() + + def test_widget_loads_within_timeout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should fully load within 15 seconds. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + + # Widget should appear within 15s + page.wait_for_selector('.pretix-widget', timeout=15000) + + # Items should be visible within 15s total + expect(page.locator( + f'.pretix-widget-item:has-text("{widget_items[0].name}")' + )).to_be_visible(timeout=15000) + + def test_no_javascript_errors_on_load( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should load without any JavaScript console errors. + """ + errors = [] + page.on('pageerror', lambda err: errors.append(str(err))) + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # No JS errors should have occurred + assert len(errors) == 0, f"JavaScript errors: {errors}" + + +@pytest.mark.django_db +class TestWidgetReload: + """Test widget behavior on page interactions.""" + + def test_widget_css_loads_correctly( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget CSS should load and apply styles. + No SCSS syntax should leak into rendered styles. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Check that widget has actual styled dimensions + # (not zero-height which would indicate CSS failure) + widget = page.locator('.pretix-widget') + box = widget.bounding_box() + assert box is not None + assert box['height'] > 50 # Widget should have meaningful height + assert box['width'] > 100 # Widget should have meaningful width diff --git a/src/tests/e2e/test_widget_pricing.py b/src/tests/e2e/test_widget_pricing.py new file mode 100644 index 0000000000..a3d64aaf76 --- /dev/null +++ b/src/tests/e2e/test_widget_pricing.py @@ -0,0 +1,257 @@ +""" +E2E Tests for Pricing & Tax Display + +Tests that verify: +- Net vs gross price display +- Tax information lines +- Mixed tax rates +- Original price strikethrough for discounts +- Free items display +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestPriceDisplay: + """Test price formatting and display.""" + + def test_price_displays_with_currency( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Item prices should display with currency code. + + Currency format should match event settings (USD). + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Check that prices are displayed with USD currency code + # General Admission is USD 50.00 + # USD is in a separate span, so check for both parts + # Use .first since there are multiple items with USD + expect(page.locator('text=/USD/').first).to_be_visible() + expect(page.locator('text=/50\\.00/').first).to_be_visible() + + def test_free_items_display_free_text( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_free, + widget_page + ): + """ + Items with price 0.00 should display "FREE" instead of $0.00. + + Makes free items more obvious to users. + """ + item = widget_item_free + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should show "FREE" text + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should contain "FREE" or "free" text (case insensitive) + expect(item_elem.locator('text=/free/i')).to_be_visible() + + def test_price_includes_decimals( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_decimals, + widget_page + ): + """ + Prices should display with proper decimal formatting. + + USD should show 2 decimal places (e.g., $25.00 not $25). + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should show price with .50 + expect(page.locator('text=/USD.*12\\.50/')).to_be_visible() + + +@pytest.mark.django_db +class TestTaxDisplay: + """Test tax information display.""" + + def test_tax_rate_displayed_for_taxed_items( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_tax, + widget_page + ): + """ + Items with tax should show tax rate information. + + Should display "incl. X% VAT" or similar. + """ + item = widget_item_with_tax + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should show tax information + # (could be "incl." or "plus" depending on settings) + # Looking for "19" and "%" near each other + expect(item_elem.locator('text=/19.*%|%.*19/')).to_be_visible() + + def test_items_without_tax_no_tax_line( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Items without tax rules should not show tax information. + + Tax line should be absent for tax-free items. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # widget_items by default have no tax + # We just verify the items display without errors + for item in widget_items: + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + +@pytest.mark.django_db +class TestDiscountedPricing: + """Test display of discounted prices.""" + + def test_widget_displays_prices_without_errors( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should display all prices without errors. + + This is a smoke test to ensure price rendering works. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # All items should display their prices + for item in widget_items: + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should have USD currency and price displayed + expect(item_elem.locator('text=/USD/')).to_be_visible() + + +@pytest.mark.django_db +class TestPriceForVariations: + """Test price display for items with variations.""" + + def test_variation_price_range_when_collapsed( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Collapsed variations should show price range (min - max). + + E.g., "$20.00 - $30.00" for variations from $20 to $30. + """ + item, _ = widget_item_with_variations + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Variations are collapsed by default + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should show price range in main row (not in hidden variations) + # Format: USD 20.00 – 30.00 (en-dash, not hyphen) + # Look specifically in the main row's price column + main_row = item_elem.locator('.pretix-widget-main-item-row') + price_col = main_row.locator('.pretix-widget-item-price-col') + expect( + price_col.locator('text=/USD.*20\\.00/') + ).to_be_visible() + expect(price_col.locator('text=/30\\.00/')).to_be_visible() + + def test_expanded_variations_show_individual_prices( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Expanded variations show individual prices for each variation. + + Each size should show its own price. + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand variations + widget_page.expand_variations(item.name) + + # Check each variation shows a price with USD currency + # We don't check exact amounts because formatting may vary + for var in variations: + var_elem = page.locator( + f'.pretix-widget-variation:has(' + f'strong:text-is("{var.value}"))' + ) + expect(var_elem).to_be_visible() + + # Should contain USD currency code + expect(var_elem.locator('text=/USD/')).to_be_visible() diff --git a/src/tests/e2e/test_widget_quantity_controls.py b/src/tests/e2e/test_widget_quantity_controls.py new file mode 100644 index 0000000000..f049d3cc94 --- /dev/null +++ b/src/tests/e2e/test_widget_quantity_controls.py @@ -0,0 +1,354 @@ +""" +E2E Tests for Quantity Controls & Order Limits + +Tests that verify: +- Checkbox display for order_max=1 items +- +/- buttons for multi-quantity items +- Order minimum/maximum enforcement +- Auto-selection of single items +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestQuantityControls: + """Test quantity control UI elements.""" + + def test_checkbox_for_single_select_item( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_single_select, + widget_page + ): + """ + Item with order_max=1 should show checkbox instead of quantity input. + + For items limited to 1 per order, a checkbox is more intuitive than + a number input. + """ + item = widget_item_single_select + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Find the item + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should have checkbox, NOT number input + expect(item_elem.locator('input[type="checkbox"]')).to_be_visible() + expect(item_elem.locator('input[type="number"]')).not_to_be_visible() + + def test_checkbox_selection( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_single_select, + widget_page + ): + """ + Checking the checkbox should select the item. + + User can check/uncheck to add/remove the item. + + Note: When there's only one item, it may be auto-selected by the widget. + This test verifies the check/uncheck functionality works regardless. + """ + item = widget_item_single_select + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + checkbox = item_elem.locator('input[type="checkbox"]') + + # Ensure checkbox is unchecked to start test + if checkbox.is_checked(): + checkbox.uncheck() + expect(checkbox).not_to_be_checked() + + # Check it + checkbox.check() + expect(checkbox).to_be_checked() + + # Uncheck it + checkbox.uncheck() + expect(checkbox).not_to_be_checked() + + def test_plus_minus_buttons_for_multi_quantity( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Items with order_max > 1 should have +/- buttons. + + Plus/minus buttons provide an easy way to increment/decrement quantity. + """ + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Regular items should have number input with +/- buttons + item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")') + + # Should have number input + expect(item_elem.locator('input[type="number"]')).to_be_visible() + + # Should have + and - buttons + expect(item_elem.locator('button:has-text("+")')).to_be_visible() + expect(item_elem.locator('button:has-text("-")')).to_be_visible() + + def test_increment_button_functionality( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Clicking + button should increase quantity by 1. + + Each click increments the value in the number input. + """ + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")') + number_input = item_elem.locator('input[type="number"]') + plus_button = item_elem.locator('button:has-text("+")').first + + # Initial value should be 0 or empty + initial_value = number_input.input_value() + if not initial_value: + initial_value = "0" + + # Click + + plus_button.click() + page.wait_for_timeout(100) + + # Should be incremented + expect(number_input).to_have_value("1") + + # Click + again + plus_button.click() + page.wait_for_timeout(100) + + expect(number_input).to_have_value("2") + + def test_decrement_button_functionality( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Clicking - button should decrease quantity by 1. + + Quantity should not go below 0. + """ + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")') + number_input = item_elem.locator('input[type="number"]') + plus_button = item_elem.locator('button:has-text("+")').first + minus_button = item_elem.locator('button:has-text("-")').first + + # Set to 2 + plus_button.click() + page.wait_for_timeout(100) + plus_button.click() + page.wait_for_timeout(100) + + expect(number_input).to_have_value("2") + + # Click - + minus_button.click() + page.wait_for_timeout(100) + + expect(number_input).to_have_value("1") + + # Click - again + minus_button.click() + page.wait_for_timeout(100) + + expect(number_input).to_have_value("0") + + def test_manual_quantity_input( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + User should be able to type quantity directly. + + Number input should accept typed values. + """ + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Directly set quantity using helper + widget_page.select_item_quantity(widget_items[0].name, 5) + + # Verify value + item_elem = page.locator(f'.pretix-widget-item:has-text("{widget_items[0].name}")') + number_input = item_elem.locator('input[type="number"]') + expect(number_input).to_have_value("5") + + +@pytest.mark.django_db +class TestOrderLimits: + """Test order minimum and maximum enforcement.""" + + def test_order_max_enforces_checkbox_for_single( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_single_select, + widget_page + ): + """Item with order_max=1 should show checkbox (implicit max enforcement).""" + item = widget_item_single_select # This has order_max=1 + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # order_max=1 items get a checkbox (max is enforced by being binary) + expect(item_elem.locator('input[type="checkbox"]')).to_be_visible() + # No number input should exist + expect(item_elem.locator('input[type="number"]')).not_to_be_visible() + + def test_submit_with_multiple_items_opens_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """Multiple items with different quantities should open iframe checkout.""" + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + widget_page.select_item_quantity(widget_items[0].name, 2) + widget_page.select_item_quantity(widget_items[1].name, 1) + + widget_page.click_buy_button() + + # Iframe checkout should open + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'take_cart_id' in src + + +@pytest.mark.django_db +class TestFreePrice: + """Test pay-what-you-want (free price) items.""" + + def test_free_price_input_appears( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_free_price, + widget_page + ): + """ + Free price item should show price input field. + + User should be able to enter their own price. + """ + item = widget_item_free_price + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should show a price input (type=number for price) + price_inputs = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]') + expect(price_inputs.first).to_be_visible() + + def test_free_price_minimum_enforcement( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_free_price, + widget_page + ): + """ + Free price input should have minimum value set. + + The min attribute should be set to the item's default price. + """ + item = widget_item_free_price + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + price_input = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]').first + + # Should have min attribute + min_value = price_input.get_attribute('min') + assert min_value is not None + assert float(min_value) == float(item.default_price) + + def test_free_price_custom_amount( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_free_price, + widget_page + ): + """ + User should be able to enter custom price amount. + + Amount above minimum should be accepted. + """ + item = widget_item_free_price + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + price_input = item_elem.locator('.pretix-widget-pricebox-price-input, input[name^="price_"]').first + + # Enter custom amount + price_input.fill("25.00") + + # Verify value + expect(price_input).to_have_value("25.00") diff --git a/src/tests/e2e/test_widget_responsive.py b/src/tests/e2e/test_widget_responsive.py new file mode 100644 index 0000000000..2cb0842275 --- /dev/null +++ b/src/tests/e2e/test_widget_responsive.py @@ -0,0 +1,96 @@ +""" +E2E Tests for Responsive Behavior + +Tests that verify: +- Mobile layout at narrow widths (pretix-widget-mobile class) +- Layout updates on resize +- Desktop layout at wide widths +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestResponsiveLayout: + """Test responsive layout behavior.""" + + def test_mobile_class_at_narrow_width( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should add pretix-widget-mobile class when + container width <= 800px. + """ + # Set narrow viewport + page.set_viewport_size({"width": 375, "height": 667}) + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should have mobile class + expect(page.locator('.pretix-widget-mobile')).to_be_visible() + + def test_no_mobile_class_at_wide_width( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should NOT have pretix-widget-mobile class when + container width > 800px. + """ + # Ensure wide viewport (default is 1280) + page.set_viewport_size({"width": 1280, "height": 720}) + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should NOT have mobile class + expect(page.locator('.pretix-widget-mobile')).to_have_count(0) + + # But widget itself should be present + expect(page.locator('.pretix-widget')).to_be_visible() + + def test_responsive_layout_updates_on_resize( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Widget should switch to mobile layout when browser is resized + from desktop to mobile width. + """ + # Start with desktop viewport + page.set_viewport_size({"width": 1280, "height": 720}) + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should not be mobile + expect(page.locator('.pretix-widget-mobile')).to_have_count(0) + + # Resize to mobile + page.set_viewport_size({"width": 375, "height": 667}) + + # Wait for ResizeObserver to fire + page.wait_for_timeout(500) + + # Should now have mobile class + expect(page.locator('.pretix-widget-mobile')).to_be_visible() diff --git a/src/tests/e2e/test_widget_variations.py b/src/tests/e2e/test_widget_variations.py new file mode 100644 index 0000000000..f78e010283 --- /dev/null +++ b/src/tests/e2e/test_widget_variations.py @@ -0,0 +1,242 @@ +""" +E2E Tests for Product Variations + +Tests that verify items with variations (sizes, colors, etc.) work correctly: +- Expand/collapse behavior +- Price ranges +- Individual variation selection +- Auto-expand when filtered +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestProductVariations: + """Test product variation functionality.""" + + def test_item_with_variations_shows_toggle_button( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Item with variations should show expand/collapse button. + + Variations should be collapsed by default with a button to expand them. + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should show item name + expect(page.locator(f'text="{item.name}"')).to_be_visible() + + # Should show variations toggle button + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + toggle_btn = item_elem.locator('button:has-text("variants"), button:has-text("Show variants")') + expect(toggle_btn.first).to_be_visible() + + def test_expand_variations_on_click( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Clicking toggle button should expand variations. + + After expanding, all variation options should be visible. + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand variations + widget_page.expand_variations(item.name) + + # Wait for variations to be visible + page.wait_for_timeout(500) # Wait for animation + + # All variations should now be visible + for variation in variations: + expect(page.locator(f'text="{variation.value}"')).to_be_visible() + + def test_collapse_variations_on_second_click( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """Clicking toggle again should collapse variations.""" + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + toggle_btn = item_elem.locator('button:has-text("variants"), button:has-text("Show variants")') + + # First click - expand + toggle_btn.first.click() + page.wait_for_timeout(500) + + # Variations should be visible + for variation in variations: + expect(page.locator(f'text="{variation.value}"')).to_be_visible() + + # Second click - collapse + toggle_btn.first.click() + page.wait_for_timeout(500) + + # Variation inputs should no longer be visible + for variation in variations: + var_row = item_elem.locator( + f'.pretix-widget-variation:has(strong:text-is("{variation.value}"))') + expect(var_row.locator('input')).not_to_be_visible() + + def test_price_range_for_collapsed_variations( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Collapsed variations should show price range. + + When variations have different prices, should display range like "$20 - $30". + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Should show price range + # Variations go from $20 to $30 + item_elem = page.locator(f'.pretix-widget-item:has-text("{item.name}")') + + # Look for price range indicators + # Format is "USD 20.00 – 30.00" in the main item's price box (first one) + price_box = item_elem.locator('.pretix-widget-pricebox').first + expect(price_box).to_contain_text('20.00') + expect(price_box).to_contain_text('30.00') + + def test_select_variation_quantity( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + User should be able to select quantity for specific variation. + + After expanding variations, each should have its own quantity selector. + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand variations + widget_page.expand_variations(item.name) + page.wait_for_timeout(500) + + # Select quantity for "Medium" variation + widget_page.select_variation_quantity(item.name, "Medium", 2) + + # Verify input has the value + medium_var = page.locator('.pretix-widget-variation:has-text("Medium")') + input_field = medium_var.locator('input[type="number"]') + expect(input_field).to_have_value("2") + + def test_variation_individual_prices( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """ + Each variation should show its individual price. + + When expanded, variations should display their specific prices. + """ + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand variations + widget_page.expand_variations(item.name) + page.wait_for_timeout(500) + + # Check each variation shows its price + variation_prices = { + 'Small': '20.00', + 'Medium': '25.00', + 'Large': '25.00', + 'X-Large': '30.00', + } + + for var_name, price in variation_prices.items(): + # Use exact heading match to avoid "Large" matching "X-Large" + var_elem = page.locator( + f'.pretix-widget-variation:has(strong:text-is("{var_name}"))' + ) + expect(var_elem).to_be_visible() + # Price should be visible within the variation element + expect(var_elem.locator(f'text=/{price}/')).to_be_visible() + + +@pytest.mark.django_db +class TestVariationSubmission: + """Test that variation selections submit correctly.""" + + def test_submit_with_variation_selection( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_with_variations, + widget_page + ): + """Submitting with a variation selected should open iframe checkout.""" + item, variations = widget_item_with_variations + + widget_page.goto_widget_test_page(live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Expand and select a variation + widget_page.expand_variations(item.name) + page.wait_for_timeout(500) + widget_page.select_variation_quantity(item.name, "Large", 1) + + widget_page.click_buy_button() + + # Iframe checkout should open with the variation in the cart + widget_page.wait_for_iframe_checkout() + + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'take_cart_id' in src diff --git a/src/tests/e2e/test_widget_vouchers.py b/src/tests/e2e/test_widget_vouchers.py new file mode 100644 index 0000000000..5fbde5cb4d --- /dev/null +++ b/src/tests/e2e/test_widget_vouchers.py @@ -0,0 +1,135 @@ +""" +E2E Tests for Voucher Redemption + +Tests that verify: +- Voucher input field appears when vouchers exist +- Voucher redemption flow works +- Voucher input hidden when disable-vouchers is set +- Voucher explanation text displays +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestVoucherDisplay: + """Test voucher input rendering.""" + + def test_voucher_input_appears_when_vouchers_exist( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_voucher, + widget_page + ): + """ + Voucher input field should be visible when event has vouchers. + + The widget checks `vouchers_exist` in the API response. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Voucher section should be visible + voucher_section = page.locator('.pretix-widget-voucher') + expect(voucher_section).to_be_visible() + + # Should have the "Redeem a voucher" heading + expect(page.locator( + '.pretix-widget-voucher-headline' + )).to_be_visible() + + # Should have the voucher input + expect(page.locator( + '.pretix-widget-voucher-input' + )).to_be_visible() + + def test_voucher_input_hidden_when_no_vouchers( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_items, + widget_page + ): + """ + Voucher input should not appear when no vouchers exist. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Voucher section should NOT be visible + voucher_section = page.locator('.pretix-widget-voucher') + expect(voucher_section).to_have_count(0) + + def test_voucher_explanation_text_displays( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_voucher, + widget_page + ): + """ + Voucher explanation text should display when configured. + """ + # Set voucher explanation text on the event + widget_event.settings.set( + 'voucher_explanation_text', + 'Enter your voucher code to get a discount.' + ) + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Explanation text should be visible + explanation = page.locator('.pretix-widget-voucher-text') + expect(explanation).to_be_visible() + expect(explanation).to_contain_text('Enter your voucher code') + + +@pytest.mark.django_db +class TestVoucherRedemption: + """Test voucher redemption flow.""" + + def test_redeem_voucher_opens_checkout( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_voucher, + widget_page + ): + """ + Entering a voucher code and clicking Redeem should open checkout. + + With skip-ssl-check (added by test harness), this opens in iframe. + """ + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Enter voucher code + voucher_input = page.locator('.pretix-widget-voucher-input') + voucher_input.fill('TESTCODE2024') + + # Click the Redeem button + page.locator( + '.pretix-widget-voucher-button-wrap button' + ).click() + + # With skip-ssl-check, voucher redemption opens in iframe + iframe = widget_page.wait_for_iframe_checkout() + + # The iframe src should contain the voucher code + iframe_elem = page.locator('iframe[name^="pretix-widget-"]') + src = iframe_elem.get_attribute('src') + assert 'TESTCODE2024' in src or 'voucher' in src diff --git a/src/tests/e2e/test_widget_waitinglist.py b/src/tests/e2e/test_widget_waitinglist.py new file mode 100644 index 0000000000..6dfc496c09 --- /dev/null +++ b/src/tests/e2e/test_widget_waitinglist.py @@ -0,0 +1,76 @@ +""" +E2E Tests for Waiting List Integration + +Tests that verify: +- Waiting list link appears for sold out items when enabled +- Waiting list link URL includes correct parameters +""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.django_db +class TestWaitingList: + """Test waiting list display for sold out items.""" + + def test_waiting_list_link_appears_when_sold_out( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out_with_waitinglist, + widget_page + ): + """ + Sold out items with waiting list enabled should show + a "Waiting list" link. + + Requires: + - Event setting: waiting_list_enabled = True + - Item: allow_waitinglist = True + - Item availability < 100 (sold out) + """ + item = widget_item_sold_out_with_waitinglist + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + # Find the sold out item + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + expect(item_elem).to_be_visible() + + # Should show waiting list link + waiting_list = item_elem.locator( + '.pretix-widget-waiting-list-link a') + expect(waiting_list).to_be_visible() + + def test_waiting_list_link_url_contains_item_id( + self, + page: Page, + live_server_url: str, + widget_organizer, + widget_event, + widget_item_sold_out_with_waitinglist, + widget_page + ): + """ + Waiting list link URL should include item ID parameter. + """ + item = widget_item_sold_out_with_waitinglist + + widget_page.goto_widget_test_page( + live_server_url, widget_organizer.slug, widget_event.slug) + widget_page.wait_for_widget_load() + + item_elem = page.locator( + f'.pretix-widget-item:has-text("{item.name}")') + waiting_list_link = item_elem.locator( + '.pretix-widget-waiting-list-link a') + + href = waiting_list_link.get_attribute('href') + assert href is not None + assert f'item={item.pk}' in href + assert 'waitinglist' in href diff --git a/src/tests/e2e/widget_test_page.html b/src/tests/e2e/widget_test_page.html new file mode 100644 index 0000000000..3f17e97640 --- /dev/null +++ b/src/tests/e2e/widget_test_page.html @@ -0,0 +1,17 @@ + + + + + + Widget Test Page + + +

Pretix Widget E2E Test Page

+ + + + + + + +