""" 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 def test_mark_server_used_unlimited_multiple_users(self, app): """All users who accept an unlimited invite are recorded in invitation_users.""" with app.app_context(): server = MediaServer( name="Test Server", server_type="jellyfin", url="http://localhost:8096", api_key="test-key", ) db.session.add(server) db.session.flush() form_data = { "expires": "never", "unlimited": True, "server_ids": [str(server.id)], } invite = create_invite(form_data) invite.code = "UNLIMULTI" db.session.commit() userA = User( username="alice", email="alice@example.com", token="token-a", code="UNLIMULTI", server_id=server.id, ) userB = User( username="bob", email="bob@example.com", token="token-b", code="UNLIMULTI", server_id=server.id, ) db.session.add_all([userA, userB]) db.session.commit() mark_server_used(invite, server.id, userA) mark_server_used(invite, server.id, userB) db.session.refresh(invite) assert userA in invite.users # type: ignore assert userB in invite.users # type: ignore assert len(invite.users) == 2 # type: ignore if __name__ == "__main__": pytest.main([__file__, "-v"])