Files
wizarr/tests/test_invitation_comprehensive.py

700 lines
25 KiB
Python

"""
Comprehensive invitation system tests with API simulation.
This test suite covers the complete invitation workflow including:
- Single and multi-server invitations
- Various media server types (Jellyfin, Plex, Audiobookshelf)
- Error scenarios and rollback behavior
- Library assignment and user permissions
- Expiry and limitation handling
"""
from datetime import UTC, datetime, timedelta
from unittest.mock import Mock, patch
import pytest
from app.extensions import db
from app.models import Invitation, Library, MediaServer, User
from app.services.invitation_manager import InvitationManager
from app.services.invites import create_invite, is_invite_valid, mark_server_used
from tests.mocks.media_server_mocks import (
create_mock_client,
get_mock_state,
setup_mock_servers,
simulate_server_failure,
simulate_user_creation_failure,
)
class TestInvitationValidation:
"""Test invitation validation logic."""
def test_valid_invitation(self, app):
"""Test that valid invitations pass validation."""
with app.app_context():
# Create a valid invitation
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Create invitation using form-like data
form_data = {
"duration": "30",
"expires": "month",
"unlimited": False,
"server_ids": [str(server.id)],
}
invite = create_invite(form_data)
# Test validation
is_valid, message = is_invite_valid(invite.code)
assert is_valid
assert message == "okay"
def test_expired_invitation(self, app):
"""Test that expired invitations are rejected."""
with app.app_context():
# Create invitation that expires immediately
invite = Invitation(
code="EXPIRED123",
expires=datetime.now(UTC) - timedelta(hours=1),
used=False,
unlimited=False,
)
db.session.add(invite)
db.session.commit()
# Test validation
is_valid, message = is_invite_valid(invite.code)
assert not is_valid
assert "expired" in message.lower()
def test_used_limited_invitation(self, app):
"""Test that used limited invitations are rejected."""
with app.app_context():
# Create used limited invitation
invite = Invitation(code="USED123", used=True, unlimited=False)
db.session.add(invite)
db.session.commit()
# Test validation
is_valid, message = is_invite_valid(invite.code)
assert not is_valid
assert "already been used" in message
def test_used_unlimited_invitation(self, app):
"""Test that used unlimited invitations are still valid."""
with app.app_context():
# Create used unlimited invitation
invite = Invitation(code="UNLIMIT123", used=True, unlimited=True)
db.session.add(invite)
db.session.commit()
# Test validation
is_valid, message = is_invite_valid(invite.code)
assert is_valid
assert message in ["okay", ""]
class TestSingleServerInvitations:
"""Test invitation processing for single media servers."""
def setup_method(self):
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_successful_jellyfin_invitation(self, mock_get_client, app):
"""Test successful invitation process for Jellyfin server."""
with app.app_context():
# Create server and invitation first
server = MediaServer(
name="Test Jellyfin",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Setup mock client with correct server ID
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(
code="JELLYFIN12",
duration="30",
used=False,
unlimited=False,
)
invite.servers = [server]
db.session.add(invite)
db.session.commit()
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="JELLYFIN12",
username="testuser",
password="testpass123",
confirm_password="testpass123",
email="test@example.com",
)
# Verify results
assert success
assert redirect_code == "JELLYFIN12"
assert len(errors) == 0
# Verify user was created in mock
mock_users = get_mock_state().users
assert len(mock_users) == 1
created_user = list(mock_users.values())[0]
assert created_user.username == "testuser"
assert created_user.email == "test@example.com"
# Verify database user was created
db_user = User.query.filter_by(code="JELLYFIN12").first()
assert db_user is not None
assert db_user.username == "testuser"
assert db_user.server_id == server.id
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_plex_invitation_with_oauth(self, mock_get_client, app):
"""Test Plex invitation that might use OAuth flow."""
with app.app_context():
# Create Plex server
server = MediaServer(
name="Test Plex",
server_type="plex",
url="http://localhost:32400",
api_key="plex-token",
)
db.session.add(server)
db.session.flush()
mock_client = create_mock_client("plex", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(
code="PLEX123",
plex_home=True,
plex_allow_sync=True,
unlimited=True,
)
invite.servers = [server]
db.session.add(invite)
db.session.commit()
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="PLEX123",
username="plexuser",
password="plexpass123",
confirm_password="plexpass123",
email="plex@example.com",
)
assert success
assert redirect_code == "PLEX123"
# Verify Plex-specific behavior
mock_users = get_mock_state().users
assert len(mock_users) == 1
created_user = list(mock_users.values())[0]
assert (
created_user.email == "plex@example.com"
) # Plex uses email as primary identifier
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_server_connection_failure(self, mock_get_client, app):
"""Test handling of server connection failures."""
with app.app_context():
# Create server and invitation
server = MediaServer(
name="Failing Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Simulate connection failure
simulate_server_failure()
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(code="FAIL123")
invite.servers = [server]
db.session.add(invite)
db.session.commit()
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="FAIL123",
username="testuser",
password="testpass123",
confirm_password="testpass123",
email="test@example.com",
)
# Should fail due to connection issue
assert not success
assert redirect_code is None
assert len(errors) > 0
assert any(
"Connection failed" in error or "server unreachable" in error.lower()
for error in errors
)
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_user_creation_failure(self, mock_get_client, app):
"""Test handling when user creation fails on media server."""
with app.app_context():
# Create server and invitation
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Simulate user creation failure
simulate_user_creation_failure(["baduser"])
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(code="BADUSER123")
invite.servers = [server]
db.session.add(invite)
db.session.commit()
# Process invitation with username that will fail
success, redirect_code, errors = InvitationManager.process_invitation(
code="BADUSER123",
username="baduser",
password="testpass123",
confirm_password="testpass123",
email="test@example.com",
)
# Should fail due to user creation issue
assert not success
assert redirect_code is None
assert len(errors) > 0
assert any("Failed to create user baduser" in error for error in errors)
class TestMultiServerInvitations:
"""Test invitation processing across multiple media servers."""
def setup_method(self):
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_multi_server_success(self, mock_get_client, app):
"""Test successful multi-server invitation."""
with app.app_context():
# Setup multiple servers
jellyfin_server = MediaServer(
name="Jellyfin Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="jellyfin-key",
)
plex_server = MediaServer(
name="Plex Server",
server_type="plex",
url="http://localhost:32400",
api_key="plex-key",
)
db.session.add_all([jellyfin_server, plex_server])
db.session.flush()
# Create multi-server invitation
invite = Invitation(code="COMP_MULTI123", duration="30", unlimited=False)
invite.servers = [jellyfin_server, plex_server]
db.session.add(invite)
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 == "plex":
return create_mock_client("plex", server_id=server.id)
return None
mock_get_client.side_effect = get_client_side_effect
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="COMP_MULTI123",
username="multiuser",
password="testpass123",
confirm_password="testpass123",
email="multi@example.com",
)
# Should succeed on both servers
assert success
assert redirect_code == "COMP_MULTI123"
assert len(errors) == 0 # No errors expected
# Verify users created on both servers
mock_users = get_mock_state().users
assert len(mock_users) == 2 # One user per server
# Verify database users
db_users = User.query.filter_by(code="COMP_MULTI123").all()
assert len(db_users) == 2
server_ids = {user.server_id for user in db_users}
# Verify that the users were created on the correct servers we just created
expected_server_ids = {jellyfin_server.id, plex_server.id}
assert server_ids == expected_server_ids
# Verify identity linking (users should share same identity)
identities = {user.identity_id for user in db_users if user.identity_id}
assert (
len(identities) <= 1
) # Should be linked to same identity or both None
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_multi_server_partial_failure(self, mock_get_client, app):
"""Test multi-server invitation where one server fails."""
with app.app_context():
# Setup servers
jellyfin_server = MediaServer(
name="Jellyfin Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="jellyfin-key",
)
plex_server = MediaServer(
name="Plex Server",
server_type="plex",
url="http://localhost:32400",
api_key="plex-key",
)
db.session.add_all([jellyfin_server, plex_server])
db.session.flush()
# Create invitation
invite = Invitation(code="PARTIAL123", unlimited=False)
invite.servers = [jellyfin_server, plex_server]
db.session.add(invite)
db.session.commit()
# Setup clients - make Plex fail
def get_client_side_effect(server):
if server.server_type == "jellyfin":
return create_mock_client("jellyfin", server_id=server.id)
if server.server_type == "plex":
# Simulate Plex failure
client = create_mock_client("plex", server_id=server.id)
client._do_join = Mock(return_value=(False, "Plex server error"))
return client
return None
mock_get_client.side_effect = get_client_side_effect
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="PARTIAL123",
username="partialuser",
password="testpass123",
confirm_password="testpass123",
email="partial@example.com",
)
# Should succeed overall (at least one server worked)
assert success
assert redirect_code == "PARTIAL123"
assert len(errors) == 1 # One error from Plex
assert "Plex server error" in errors[0]
# Verify only Jellyfin user was created
mock_users = get_mock_state().users
jellyfin_users = [
u for u in mock_users.values() if "Plex server error" not in str(u)
]
assert len(jellyfin_users) == 1
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_multi_server_complete_failure(self, mock_get_client, app):
"""Test multi-server invitation where all servers fail."""
with app.app_context():
# Setup servers
server1 = MediaServer(
name="Server 1",
server_type="jellyfin",
url="http://localhost:8096",
api_key="key1",
)
server2 = MediaServer(
name="Server 2",
server_type="plex",
url="http://localhost:32400",
api_key="key2",
)
db.session.add_all([server1, server2])
db.session.flush()
# Create invitation
invite = Invitation(code="ALLFAIL123", unlimited=False)
invite.servers = [server1, server2]
db.session.add(invite)
db.session.commit()
# Make all servers fail
def failing_client(server):
client = create_mock_client("jellyfin", server_id=server.id)
client._do_join = Mock(return_value=(False, f"{server.name} failed"))
return client
mock_get_client.side_effect = failing_client
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="ALLFAIL123",
username="failuser",
password="testpass123",
confirm_password="testpass123",
email="fail@example.com",
)
# Should fail completely
assert not success
assert redirect_code is None
assert len(errors) == 2 # Errors from both servers
assert all("failed" in error for error in errors)
class TestInvitationLibraryAssignment:
"""Test library assignment during invitation processing."""
def setup_method(self):
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_specific_library_assignment(self, mock_get_client, app):
"""Test invitation with specific library restrictions."""
with app.app_context():
# Setup server
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Create libraries
lib1 = Library(
name="Movies", external_id="lib1", server_id=server.id, enabled=True
)
lib2 = Library(
name="TV Shows", external_id="lib2", server_id=server.id, enabled=True
)
lib3 = Library(
name="Music", external_id="lib3", server_id=server.id, enabled=True
)
db.session.add_all([lib1, lib2, lib3])
db.session.flush()
# Create invitation with specific libraries
invite = Invitation(code="SPECIFIC12")
invite.servers = [server]
invite.libraries = [lib1, lib2] # Only Movies and TV
db.session.add(invite)
db.session.commit()
# Setup mock client
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="SPECIFIC12",
username="libuser",
password="testpass123",
confirm_password="testpass123",
email="lib@example.com",
)
assert success
# Verify user was assigned only specific libraries
mock_users = get_mock_state().users
assert len(mock_users) == 1
created_user = list(mock_users.values())[0]
# Should have access to lib1 and lib2, but not lib3
expected_libs = ["lib1", "lib2"]
assert set(created_user.libraries) == set(expected_libs)
class TestInvitationExpiry:
"""Test invitation expiry and duration handling."""
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_user_expiry_calculation(self, mock_get_client, app):
"""Test that users get proper expiry dates based on invitation duration."""
with app.app_context():
# Setup server
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Create invitation with 7-day duration
invite = Invitation(
code="EXPIRY123",
duration="7", # 7 days
)
invite.servers = [server]
db.session.add(invite)
db.session.commit()
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
# Process invitation
success, redirect_code, errors = InvitationManager.process_invitation(
code="EXPIRY123",
username="expiryuser",
password="testpass123",
confirm_password="testpass123",
email="expiry@example.com",
)
assert success
# Verify user has expiry date set
db_user = User.query.filter_by(code="EXPIRY123").first()
assert db_user is not None
assert db_user.expires is not None
# Should expire in approximately 7 days
# Database stores naive UTC, so compare with UTC time
expected_expiry = datetime.now(UTC) + timedelta(days=7)
time_diff = abs(
(db_user.expires - expected_expiry.replace(tzinfo=None)).total_seconds()
)
assert time_diff < 60 # Within 1 minute of expected
class TestInvitationMarkingUsed:
"""Test invitation usage tracking and server marking."""
def test_mark_server_used_single_server(self, app):
"""Test marking invitation as used for single server."""
with app.app_context():
# Create server
server = MediaServer(
name="Test Server",
server_type="jellyfin",
url="http://localhost:8096",
api_key="test-key",
)
db.session.add(server)
db.session.flush()
# Create invitation properly with servers relationship
form_data = {
"expires": "month",
"unlimited": False,
"server_ids": [str(server.id)],
}
invite = create_invite(form_data)
invite.code = "MARK123" # Set specific code for test
user = User(
username="testuser",
email="test@example.com",
token="user-token",
code="MARK123",
server_id=server.id,
)
db.session.add(user)
db.session.commit()
# Mark server as used
mark_server_used(invite, server.id, user)
# Verify invitation is marked as used (since only one server and it's limited)
db.session.refresh(invite)
assert invite.used is True
assert invite.used_by == user
# Verify user is in invitation's users collection
assert user in invite.users # type: ignore
def test_mark_server_used_multi_server_partial(self, app):
"""Test marking one server as used in multi-server invitation."""
with app.app_context():
# Create multiple servers
server1 = MediaServer(
name="Server 1",
server_type="jellyfin",
url="http://localhost:8096",
api_key="key1",
)
server2 = MediaServer(
name="Server 2",
server_type="plex",
url="http://localhost:32400",
api_key="key2",
)
db.session.add_all([server1, server2])
db.session.flush()
# Create invitation for both servers
invite = Invitation(code="MULTIMARK123", used=False, unlimited=False)
invite.servers = [server1, server2]
db.session.add(invite)
db.session.flush()
# Create user for first server
user1 = User(
username="user1",
email="user1@example.com",
token="token1",
code="MULTIMARK123",
server_id=server1.id,
)
db.session.add(user1)
db.session.commit()
# Mark only first server as used
mark_server_used(invite, server1.id, user1)
db.session.commit()
# Invitation should not be fully used yet (limited invitation with multiple servers)
db.session.refresh(invite)
assert invite.used is False # Not all servers used yet
# But user should be tracked
assert user1 in invite.users # type: ignore
if __name__ == "__main__":
pytest.main([__file__, "-v"])