Files
wizarr/tests/e2e/test_invitation_e2e.py
Matthieu B 7803cfd74c Keep invite form open on errors
Keep form-based invitation screens open on account creation failures so users can see errors without replaying the welcome animation.

Centralize join form template context, preserve theming across invite routes, and add regression coverage for validation and E2E flows.
2026-07-05 18:53:49 +02:00

610 lines
24 KiB
Python

"""
End-to-end tests for invitation workflows using Playwright.
These tests simulate the complete user journey from receiving an invitation
link to successfully creating accounts on media servers.
"""
import contextlib
import multiprocessing
import os
import tempfile
from unittest.mock import patch
import pytest
from playwright.sync_api import Page, expect # type: ignore
# Fix for Python 3.14+ multiprocessing compatibility with pytest-flask live_server
# GitHub Actions uses spawn/forkserver by default which can't pickle local functions
# Force 'fork' method before any fixtures initialize
with contextlib.suppress(RuntimeError):
# RuntimeError raised if method already set, which is fine
multiprocessing.set_start_method("fork", force=True)
from app import create_app
from app.config import BaseConfig
from app.extensions import db
from app.models import AdminAccount, Invitation, MediaServer, Settings
from tests.mocks.media_server_mocks import (
create_mock_client,
setup_mock_servers,
)
class E2ETestConfig(BaseConfig):
TESTING = True
WTF_CSRF_ENABLED = False
# Use a temporary file database that both test process and live server can access
SQLALCHEMY_DATABASE_URI = f"sqlite:///{tempfile.gettempdir()}/wizarr_e2e_test.db"
@pytest.fixture(scope="session")
def app():
"""Create app with file-based database for E2E tests."""
# Clean up any existing test database
test_db_path = f"{tempfile.gettempdir()}/wizarr_e2e_test.db"
if os.path.exists(test_db_path):
os.remove(test_db_path)
app = create_app(E2ETestConfig) # type: ignore
with app.app_context():
db.create_all()
yield app
# Cleanup after tests
with app.app_context():
db.drop_all()
if os.path.exists(test_db_path):
os.remove(test_db_path)
@pytest.fixture
def invitation_setup(app):
"""Setup test data for invitation E2E tests."""
with app.app_context():
setup_mock_servers()
# Create admin account if it doesn't exist (to bypass setup redirect)
admin = AdminAccount.query.filter_by(username="testadmin").first()
changes_made = False
if not admin:
admin = AdminAccount(username="testadmin")
admin.set_password("testpass")
db.session.add(admin)
changes_made = True
# Create admin_username setting to complete setup (if not exists)
admin_setting = Settings.query.filter_by(key="admin_username").first()
if not admin_setting:
admin_setting = Settings(key="admin_username", value="testadmin")
db.session.add(admin_setting)
changes_made = True
# Commit admin setup changes if any were made
if changes_made:
db.session.commit()
# Create media server
server = MediaServer(
name="Test Jellyfin Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-api-key",
)
db.session.add(server)
db.session.flush()
# Create valid invitation
invitation = Invitation(
code="E2ETEST123",
duration="30",
used=False,
unlimited=False,
)
invitation.servers = [server]
db.session.add(invitation)
db.session.commit()
yield {
"server": server,
"invitation": invitation,
"invitation_url": f"/j/{invitation.code}",
}
class TestInvitationUserJourney:
"""Test complete user journey through invitation process."""
@patch("app.services.media.service.get_client_for_media_server")
def test_successful_invitation_flow(
self, mock_get_client, page: Page, live_server, invitation_setup
):
"""Test successful invitation acceptance and account creation."""
# Setup mock client
mock_client = create_mock_client(
"jellyfin", server_id=invitation_setup["server"].id
)
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
# Verify invitation page loads
expect(page.locator("h1").first).to_contain_text("been invited")
# Click "Accept Invitation" button to show the form
page.click("#accept-invite-btn")
# Wait for form fields to become visible (they animate in sequentially)
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
# Check that form is present after clicking Accept Invitation
expect(page.locator("form")).to_be_visible()
expect(page.locator("input[name='username']")).to_be_visible()
expect(page.locator("input[name='password']")).to_be_visible()
expect(page.locator("input[name='confirm_password']")).to_be_visible()
expect(page.locator("input[name='email']")).to_be_visible()
# Fill out the form
page.fill("input[name='username']", "e2etestuser")
page.fill("input[name='password']", "Testpassword123")
page.fill("input[name='confirm_password']", "Testpassword123")
page.fill("input[name='email']", "e2etest@example.com")
# Submit the form
expect(page.locator("button[type='submit']")).to_be_visible()
page.click("button[type='submit']")
# Wait for form submission to complete
page.wait_for_load_state("networkidle")
# Since we can't mock the external API calls in E2E tests,
# we expect this to fail and show an error message
# In a real environment, this would connect to actual servers
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
# Note: In a true E2E test environment, you would verify
# that the user was created in a test media server
@patch("app.services.media.service.get_client_for_media_server")
def test_invitation_form_validation(
self, mock_get_client, page: Page, live_server, invitation_setup
):
"""Test form validation on invitation page."""
mock_client = create_mock_client(
"jellyfin", server_id=invitation_setup["server"].id
)
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
# Click "Accept Invitation" button to show the form
expect(page.locator("#accept-invite-btn")).to_be_visible()
page.click("#accept-invite-btn")
# Wait for the animation timeline to complete and form fields to become fully visible
# The animation takes ~800ms + field stagger delays (80ms * 6 fields = 480ms) + buffer
page.wait_for_timeout(2000)
# Wait for form fields to become visible and interactive (with proper CSS opacity)
page.wait_for_selector(
"input[name='username']:not([style*='opacity: 0'])", timeout=10000
)
page.wait_for_selector(
"input[name='password']:not([style*='opacity: 0'])", timeout=5000
)
page.wait_for_selector(
"input[name='email']:not([style*='opacity: 0'])", timeout=5000
)
page.wait_for_load_state("networkidle")
# Test empty form submission - wait for submit button to be clickable
expect(page.locator("button[type='submit']")).to_be_visible()
page.click("button[type='submit']")
# Client-side validation should prevent submission of empty form
page.wait_for_load_state("networkidle")
# Verify we're still on the invitation page (form wasn't submitted)
expect(page.locator("body")).to_contain_text("been invited")
# Test password mismatch - wait for form to be ready
page.wait_for_selector(
"input[name='username']:not([style*='opacity: 0'])", timeout=5000
)
page.fill("input[name='username']", "testuser")
page.fill("input[name='password']", "Password123")
page.fill("input[name='confirm_password']", "Differentpassword123")
page.fill("input[name='email']", "test@example.com")
expect(page.locator("button[type='submit']")).to_be_visible()
page.click("button[type='submit']")
# Should show password mismatch error or stay on same page
page.wait_for_load_state("networkidle")
expect(page.locator("body")).to_contain_text("been invited")
# Form validation test is complete - the form correctly prevented invalid submissions
def test_expired_invitation(self, page: Page, live_server, app):
"""Test that expired invitations show appropriate error."""
with app.app_context():
from datetime import UTC, datetime, timedelta
# Create expired invitation
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
expired_invitation = Invitation(
code="EXPIRED123",
expires=datetime.now(UTC) - timedelta(hours=1),
used=False,
)
expired_invitation.servers = [server]
db.session.add(expired_invitation)
db.session.commit()
# Navigate to expired invitation
page.goto(f"{live_server.url()}/j/EXPIRED123")
# Should show expiry error
expect(page.locator("body")).to_contain_text("expired", ignore_case=True)
# Form should not be present
expect(page.locator("form")).not_to_be_visible()
def test_invalid_invitation_code(self, page: Page, live_server):
"""Test that invalid invitation codes show 404 or error page."""
# Navigate to non-existent invitation
page.goto(f"{live_server.url()}/j/INVALIDCODE123")
# Should show error (either 404 or invalid invitation message)
page.wait_for_load_state("networkidle")
# Check for either 404 or invalid invitation message
body_text = page.locator("body").inner_text().lower()
assert any(
phrase in body_text
for phrase in ["not found", "invalid", "expired", "does not exist", "404"]
)
@patch("app.services.media.service.get_client_for_media_server")
def test_server_error_handling(
self, mock_get_client, page: Page, live_server, invitation_setup
):
"""Test handling when media server is unavailable."""
# Setup failing mock client
mock_client = create_mock_client(
"jellyfin", server_id=invitation_setup["server"].id
)
mock_client._do_join = lambda *args, **kwargs: (
False,
"Server temporarily unavailable",
)
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
# Click "Accept Invitation" button to show the form
page.click("#accept-invite-btn")
# Wait for form fields to become visible (they animate in sequentially)
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
# Fill and submit form
page.fill("input[name='username']", "erroruser")
page.fill("input[name='password']", "Testpass123")
page.fill("input[name='confirm_password']", "Testpass123")
page.fill("input[name='email']", "error@example.com")
expect(page.locator("button[type='submit']")).to_be_visible()
page.click("button[type='submit']")
# Should show error (connection refused since no real server running)
page.wait_for_load_state("networkidle")
expect(page.locator("body")).to_contain_text("error", ignore_case=True)
# After error, we might need to click accept invitation again to show form
try:
expect(page.locator("form")).to_be_visible(timeout=2000)
except AssertionError:
# If form not visible, try clicking accept invitation button again
if page.locator("#accept-invite-btn").is_visible():
page.click("#accept-invite-btn")
page.wait_for_selector(
"input[name='username']:not([style*='opacity: 0'])", timeout=10000
)
expect(page.locator("form")).to_be_visible()
class TestMultiServerInvitationFlow:
"""Test E2E flows for multi-server invitations."""
@patch("app.services.media.service.get_client_for_media_server")
def test_multi_server_invitation_success(
self, mock_get_client, page: Page, live_server, app
):
"""Test successful multi-server invitation flow."""
with app.app_context():
setup_mock_servers()
# Create admin account if it doesn't exist (to bypass setup redirect)
admin = AdminAccount.query.filter_by(username="testadmin").first()
changes_made = False
if not admin:
admin = AdminAccount(username="testadmin")
admin.set_password("testpass")
db.session.add(admin)
changes_made = True
# Create admin_username setting to complete setup (if not exists)
admin_setting = Settings.query.filter_by(key="admin_username").first()
if not admin_setting:
admin_setting = Settings(key="admin_username", value="testadmin")
db.session.add(admin_setting)
changes_made = True
# Commit admin setup changes if any were made
if changes_made:
db.session.commit()
# Create multiple non-Plex servers to avoid OAuth flow
jellyfin_server = MediaServer(
name="Jellyfin Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="jellyfin-key",
)
emby_server = MediaServer(
name="Emby Server",
server_type="emby",
url="http://localhost:8920",
api_key="emby-key",
)
db.session.add_all([jellyfin_server, emby_server])
db.session.flush()
# Create multi-server invitation with unique code
invitation = Invitation(
code="MULTI1",
duration="30",
used=False,
unlimited=False,
)
invitation.servers = [jellyfin_server, emby_server]
db.session.add(invitation)
db.session.commit()
# Setup mock clients
def get_client_side_effect(server):
if server.server_type == "jellyfin":
return create_mock_client("jellyfin", server_id=server.id)
if server.server_type == "emby":
return create_mock_client("emby", server_id=server.id)
return None
mock_get_client.side_effect = get_client_side_effect
# Navigate to invitation page
page.goto(f"{live_server.url()}/j/MULTI1")
# Wait for page to load
page.wait_for_load_state("networkidle")
# Should show invitation content (may not show specific server names)
expect(page.locator("body")).to_contain_text("been invited")
# Click "Accept Invitation" button to show the form
page.click("#accept-invite-btn")
# Wait for form fields to become visible (they animate in sequentially)
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
# Wait for form to be visible
page.wait_for_selector("input[name='username']", timeout=5000)
# Fill and submit the user registration form
page.fill("input[name='username']", "multiuser")
page.fill("input[name='password']", "Testpass123")
page.fill("input[name='confirm_password']", "Testpass123")
page.fill("input[name='email']", "multi@example.com")
page.click("button:has-text('Create Account')")
# Wait for form submission to complete
page.wait_for_load_state("networkidle")
# Since we can't mock the external API calls in E2E tests with live server,
# we expect this to fail and show an error message when servers are unreachable
# In a real environment with running servers, this would succeed
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
@patch("app.services.media.service.get_client_for_media_server")
def test_multi_server_partial_failure(
self, mock_get_client, page: Page, live_server, app
):
"""Test multi-server invitation with partial failures."""
with app.app_context():
setup_mock_servers()
# Create admin account if it doesn't exist (to bypass setup redirect)
admin = AdminAccount.query.filter_by(username="testadmin").first()
changes_made = False
if not admin:
admin = AdminAccount(username="testadmin")
admin.set_password("testpass")
db.session.add(admin)
changes_made = True
# Create admin_username setting to complete setup (if not exists)
admin_setting = Settings.query.filter_by(key="admin_username").first()
if not admin_setting:
admin_setting = Settings(key="admin_username", value="testadmin")
db.session.add(admin_setting)
changes_made = True
# Commit admin setup changes if any were made
if changes_made:
db.session.commit()
# Create servers (avoid Plex to skip OAuth)
jellyfin_server = MediaServer(
name="Working Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="working-key",
)
broken_server = MediaServer(
name="Broken Server",
server_type="emby",
url="http://localhost:8920",
api_key="broken-key",
)
db.session.add_all([jellyfin_server, broken_server])
db.session.flush()
# Use unique invitation code
invitation = Invitation(
code="PARTIAL1",
duration="30",
used=False,
unlimited=False,
)
invitation.servers = [jellyfin_server, broken_server]
db.session.add(invitation)
db.session.commit()
# Setup clients - one working, one failing
def get_client_side_effect(server):
if server.name == "Working Server":
return create_mock_client("jellyfin", server_id=server.id)
# Create failing Emby client
client = create_mock_client("emby", server_id=server.id)
client._do_join = lambda *args, **kwargs: (
False,
"Server is down for maintenance",
)
return client
mock_get_client.side_effect = get_client_side_effect
# Navigate and submit
page.goto(f"{live_server.url()}/j/PARTIAL1")
# Wait for page to load
page.wait_for_load_state("networkidle")
# Click "Accept Invitation" button to show the form
page.click("#accept-invite-btn")
# Wait for form fields to become visible (they animate in sequentially)
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
# Should show invitation content and registration form
page.wait_for_selector("input[name='username']", timeout=10000)
page.fill("input[name='username']", "partialuser")
page.fill("input[name='password']", "Testpass123")
page.fill("input[name='confirm_password']", "Testpass123")
page.fill("input[name='email']", "partial@example.com")
page.click("button:has-text('Create Account')")
# Wait for form submission to complete
page.wait_for_load_state("networkidle")
# Since servers are unreachable in E2E environment, expect error message
# In a real environment with one working and one broken server,
# this would show partial success
expect(page.locator("body")).to_contain_text("Error", ignore_case=True)
class TestInvitationUIComponents:
"""Test UI components and interactions on invitation pages."""
def test_invitation_page_accessibility(
self, page: Page, live_server, invitation_setup
):
"""Test basic accessibility of invitation form."""
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
# Click "Accept Invitation" button to show the form
page.click("#accept-invite-btn")
# Wait for form fields to become visible (they animate in sequentially)
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
# Check for form labels (they don't have 'for' attributes but are present)
expect(page.locator("label").filter(has_text="Username")).to_be_visible()
expect(page.locator("label").filter(has_text="Password").first).to_be_visible()
expect(page.locator("label").filter(has_text="Email")).to_be_visible()
# Check form uses POST method (lowercase is HTML standard)
expect(page.locator("form")).to_have_attribute("method", "post")
# Test keyboard navigation
page.keyboard.press("Tab") # Should focus first input
focused_element = page.evaluate(
"document.activeElement.name || document.activeElement.getAttribute('name')"
)
assert (
focused_element
in ["username", "password", "email", "confirm_password", "code"]
or focused_element is None
)
def test_responsive_design(self, page: Page, live_server, invitation_setup):
"""Test invitation page on different screen sizes."""
# Test desktop
page.set_viewport_size({"width": 1920, "height": 1080})
page.goto(f"{live_server.url()}{invitation_setup['invitation_url']}")
page.click("#accept-invite-btn")
# Wait for form fields to become visible
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
expect(page.locator("form")).to_be_visible()
# Test tablet
page.set_viewport_size({"width": 768, "height": 1024})
page.reload()
page.click("#accept-invite-btn")
# Wait for form fields to become visible
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
expect(page.locator("form")).to_be_visible()
# Test mobile
page.set_viewport_size({"width": 375, "height": 667})
page.reload()
page.click("#accept-invite-btn")
# Wait for form fields to become visible
page.wait_for_selector(
"input[name='username'][style*='opacity: 1'], input[name='username']:not([style*='opacity: 0'])",
timeout=10000,
)
expect(page.locator("form")).to_be_visible()
# Form should remain usable at all sizes
expect(page.locator("input[name='username']")).to_be_visible()
expect(page.locator("button[type='submit']")).to_be_visible()
if __name__ == "__main__":
pytest.main([__file__, "-v", "--headed"]) # Run with visible browser for debugging