diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d280de0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 20adba1..202af1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,12 @@ h11==0.16.0 httpcore==1.0.9 httpx==0.28.1 idna==3.10 +iniconfig==2.1.0 +packaging==25.0 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.1 +pytest-asyncio==1.1.0 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 python-telegram-bot==22.2 diff --git a/test/test_crossfit_booker.py b/test/test_crossfit_booker.py new file mode 100755 index 0000000..3af3797 --- /dev/null +++ b/test/test_crossfit_booker.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Test script for the refactored CrossFitBooker functional implementation. +""" + +import sys +import os +from datetime import datetime +import pytz +from typing import List, Dict, Any, Tuple + +# Add the current directory to the path so we can import our modules +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# Import the functional functions from our refactored code +# Import the functional functions from our refactored code +import sys +import os +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from crossfit_booker_functional import ( + is_session_bookable, + matches_preferred_session, + filter_bookable_sessions, + filter_upcoming_sessions, + filter_preferred_sessions, + categorize_sessions, + format_session_details +) + +def test_is_session_bookable(): + """Test the is_session_bookable function.""" + print("Testing is_session_bookable...") + + # Test case 1: Session with can_join = True + session1 = { + "user_info": { + "can_join": True + } + } + current_time = datetime.now(pytz.timezone("Europe/Paris")) + assert is_session_bookable(session1, current_time, "Europe/Paris") == True + + # Test case 2: Session with booking window in the past + session2 = { + "user_info": { + "unableToBookUntilDate": "01-01-2020", + "unableToBookUntilTime": "10:00" + } + } + assert is_session_bookable(session2, current_time, "Europe/Paris") == True + + # Test case 3: Session with booking window in the future + session3 = { + "user_info": { + "unableToBookUntilDate": "01-01-2030", + "unableToBookUntilTime": "10:00" + } + } + assert is_session_bookable(session3, current_time, "Europe/Paris") == False + + print("✓ is_session_bookable tests passed") + + +def test_matches_preferred_session(): + """Test the matches_preferred_session function.""" + print("Testing matches_preferred_session...") + + # Define some preferred sessions (day_of_week, start_time, session_name_contains) + preferred_sessions: List[Tuple[int, str, str]] = [ + (2, "18:30", "CONDITIONING"), # Wednesday 18:30 CONDITIONING + (4, "17:00", "WEIGHTLIFTING"), # Friday 17:00 WEIGHTLIFTING + (5, "12:30", "HYROX"), # Saturday 12:30 HYROX + ] + + # Test case 1: Exact match + session1 = { + "start_timestamp": "2025-07-30 18:30:00", # Wednesday + "name_activity": "CONDITIONING" + } + assert matches_preferred_session(session1, preferred_sessions, "Europe/Paris") == True + + # Test case 2: No match + session2 = { + "start_timestamp": "2025-07-30 18:30:00", # Wednesday + "name_activity": "YOGA" + } + assert matches_preferred_session(session2, preferred_sessions, "Europe/Paris") == False + + print("✓ matches_preferred_session tests passed") + + +def test_filter_functions(): + """Test the filter functions.""" + print("Testing filter functions...") + + # Define some preferred sessions + preferred_sessions: List[Tuple[int, str, str]] = [ + (2, "18:30", "CONDITIONING"), # Wednesday 18:30 CONDITIONING + (4, "17:00", "WEIGHTLIFTING"), # Friday 17:00 WEIGHTLIFTING + (5, "12:30", "HYROX"), # Saturday 12:30 HYROX + ] + + # Create some test sessions + current_time = datetime.now(pytz.timezone("Europe/Paris")) + + sessions = [ + { + "start_timestamp": "2025-07-30 18:30:00", # Wednesday + "name_activity": "CONDITIONING", + "user_info": {"can_join": True} + }, + { + "start_timestamp": "2025-07-30 19:00:00", # Wednesday + "name_activity": "YOGA", + "user_info": {"can_join": True} + }, + { + "start_timestamp": "2025-08-01 17:00:00", # Friday + "name_activity": "WEIGHTLIFTING", + "user_info": {"can_join": True} + } + ] + + # Test filter_preferred_sessions + preferred = filter_preferred_sessions(sessions, preferred_sessions, "Europe/Paris") + assert len(preferred) == 2 # CONDITIONING and WEIGHTLIFTING sessions + + # Test filter_bookable_sessions + bookable = filter_bookable_sessions(sessions, current_time, preferred_sessions, "Europe/Paris") + assert len(bookable) == 2 # Both preferred sessions are bookable + + print("✓ Filter function tests passed") + + +def test_categorize_sessions(): + """Test the categorize_sessions function.""" + print("Testing categorize_sessions...") + + # Define some preferred sessions + preferred_sessions: List[Tuple[int, str, str]] = [ + (2, "18:30", "CONDITIONING"), # Wednesday 18:30 CONDITIONING + (4, "17:00", "WEIGHTLIFTING"), # Friday 17:00 WEIGHTLIFTING + (5, "12:30", "HYROX"), # Saturday 12:30 HYROX + ] + + # Create some test sessions + current_time = datetime.now(pytz.timezone("Europe/Paris")) + + sessions = [ + { + "start_timestamp": "2025-07-30 18:30:00", # Wednesday + "name_activity": "CONDITIONING", + "user_info": {"can_join": True} + }, + { + "start_timestamp": "2025-07-31 18:30:00", # Thursday (tomorrow relative to Wednesday) + "name_activity": "CONDITIONING", + "user_info": {"can_join": False, "unableToBookUntilDate": "01-08-2025", "unableToBookUntilTime": "10:00"} + } + ] + + # Test categorize_sessions + categorized = categorize_sessions(sessions, current_time, preferred_sessions, "Europe/Paris") + assert "bookable" in categorized + assert "upcoming" in categorized + assert "all_preferred" in categorized + + print("✓ categorize_sessions tests passed") + + +def test_format_session_details(): + """Test the format_session_details function.""" + print("Testing format_session_details...") + + # Test case 1: Valid session + session1 = { + "start_timestamp": "2025-07-30 18:30:00", + "name_activity": "CONDITIONING" + } + formatted = format_session_details(session1, "Europe/Paris") + assert "CONDITIONING" in formatted + assert "2025-07-30 18:30" in formatted + + # Test case 2: Session with missing data + session2 = { + "name_activity": "WEIGHTLIFTING" + } + formatted = format_session_details(session2, "Europe/Paris") + assert "WEIGHTLIFTING" in formatted + assert "Unknown time" in formatted + + print("✓ format_session_details tests passed") + + +def run_all_tests(): + """Run all tests.""" + print("Running all tests for CrossFitBooker functional implementation...\n") + + test_is_session_bookable() + test_matches_preferred_session() + test_filter_functions() + test_categorize_sessions() + test_format_session_details() + + print("\n✓ All tests passed!") + + +if __name__ == "__main__": + run_all_tests() \ No newline at end of file diff --git a/test/test_crossfit_booker_auth.py b/test/test_crossfit_booker_auth.py new file mode 100644 index 0000000..b4e9ab0 --- /dev/null +++ b/test/test_crossfit_booker_auth.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Unit tests for CrossFitBooker authentication methods +""" + +import pytest +import os +import sys +from unittest.mock import patch, Mock + +# Add the parent directory to the path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from crossfit_booker import CrossFitBooker + + +class TestCrossFitBookerAuthHeaders: + """Test cases for get_auth_headers method""" + + def test_get_auth_headers_without_token(self): + """Test headers without auth token""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + headers = booker.get_auth_headers() + assert "Authorization" not in headers + assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0" + + def test_get_auth_headers_with_token(self): + """Test headers with auth token""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token_123" + headers = booker.get_auth_headers() + assert headers["Authorization"] == "Bearer test_token_123" + + +class TestCrossFitBookerLogin: + """Test cases for login method""" + + @patch('requests.Session.post') + def test_login_success(self, mock_post): + """Test successful login flow""" + # Mock first login response + mock_response1 = Mock() + mock_response1.ok = True + mock_response1.json.return_value = { + "data": { + "user": { + "id_user": "12345" + } + } + } + + # Mock second login response + mock_response2 = Mock() + mock_response2.ok = True + mock_response2.json.return_value = { + "token": "test_bearer_token" + } + + mock_post.side_effect = [mock_response1, mock_response2] + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is True + assert booker.user_id == "12345" + assert booker.auth_token == "test_bearer_token" + + @patch('requests.Session.post') + def test_login_first_step_failure(self, mock_post): + """Test login failure on first step""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 400 + mock_response.text = "Bad Request" + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + assert booker.user_id is None + assert booker.auth_token is None + + @patch('requests.Session.post') + def test_login_second_step_failure(self, mock_post): + """Test login failure on second step""" + # First response succeeds + mock_response1 = Mock() + mock_response1.ok = True + mock_response1.json.return_value = { + "data": { + "user": { + "id_user": "12345" + } + } + } + + # Second response fails + mock_response2 = Mock() + mock_response2.ok = False + mock_response2.status_code = 401 + + mock_post.side_effect = [mock_response1, mock_response2] + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + + @patch('requests.Session.post') + def test_login_json_parsing_error(self, mock_post): + """Test login with JSON parsing error""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.side_effect = ValueError("Invalid JSON") + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + + @patch('requests.Session.post') + def test_login_request_exception(self, mock_post): + """Test login with request exception""" + mock_post.side_effect = requests.exceptions.ConnectionError("Network error") + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/test/test_crossfit_booker_final.py b/test/test_crossfit_booker_final.py new file mode 100644 index 0000000..446f625 --- /dev/null +++ b/test/test_crossfit_booker_final.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Comprehensive unit tests for the CrossFitBooker class in crossfit_booker.py +""" + +import pytest +import os +import sys +from unittest.mock import Mock, patch, MagicMock, AsyncMock +from datetime import datetime, date, timedelta +import pytz +import requests +from typing import Dict, Any, List + +# Add the parent directory to the path to import crossfit_booker +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from crossfit_booker import CrossFitBooker + + +class TestCrossFitBookerInit: + """Test cases for CrossFitBooker initialization""" + + def test_init_success(self): + """Test successful initialization with all required env vars""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass', + 'EMAIL_FROM': 'from@test.com', + 'EMAIL_TO': 'to@test.com', + 'EMAIL_PASSWORD': 'email_pass', + 'TELEGRAM_TOKEN': 'telegram_token', + 'TELEGRAM_CHAT_ID': '12345' + }): + booker = CrossFitBooker() + assert booker.auth_token is None + assert booker.user_id is None + assert booker.session is not None + assert booker.notifier is not None + + def test_init_missing_credentials(self): + """Test initialization fails with missing credentials""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="Missing environment variables"): + CrossFitBooker() + + def test_init_partial_credentials(self): + """Test initialization fails with partial credentials""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user' + # Missing PASSWORD + }): + with pytest.raises(ValueError, match="Missing environment variables"): + CrossFitBooker() + + +class TestCrossFitBookerAuthHeaders: + """Test cases for get_auth_headers method""" + + def test_get_auth_headers_without_token(self): + """Test headers without auth token""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + headers = booker.get_auth_headers() + assert "Authorization" not in headers + assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0" + + def test_get_auth_headers_with_token(self): + """Test headers with auth token""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token_123" + headers = booker.get_auth_headers() + assert headers["Authorization"] == "Bearer test_token_123" + + +class TestCrossFitBookerLogin: + """Test cases for login method""" + + @patch('requests.Session.post') + def test_login_success(self, mock_post): + """Test successful login flow""" + # Mock first login response + mock_response1 = Mock() + mock_response1.ok = True + mock_response1.json.return_value = { + "data": { + "user": { + "id_user": "12345" + } + } + } + + # Mock second login response + mock_response2 = Mock() + mock_response2.ok = True + mock_response2.json.return_value = { + "token": "test_bearer_token" + } + + mock_post.side_effect = [mock_response1, mock_response2] + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is True + assert booker.user_id == "12345" + assert booker.auth_token == "test_bearer_token" + + @patch('requests.Session.post') + def test_login_first_step_failure(self, mock_post): + """Test login failure on first step""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 400 + mock_response.text = "Bad Request" + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + assert booker.user_id is None + assert booker.auth_token is None + + @patch('requests.Session.post') + def test_login_json_parsing_error(self, mock_post): + """Test login with JSON parsing error""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.side_effect = ValueError("Invalid JSON") + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + + @patch('requests.Session.post') + def test_login_request_exception(self, mock_post): + """Test login with request exception""" + mock_post.side_effect = requests.exceptions.ConnectionError("Network error") + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.login() + + assert result is False + + +class TestCrossFitBookerGetAvailableSessions: + """Test cases for get_available_sessions method""" + + def test_get_available_sessions_no_auth(self): + """Test get_available_sessions without authentication""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25)) + assert result is None + + @patch('requests.Session.post') + def test_get_available_sessions_success(self, mock_post): + """Test successful get_available_sessions""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "data": { + "activities_calendar": [ + {"id": "1", "name": "Test Session"} + ] + } + } + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token" + booker.user_id = "12345" + + result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25)) + + assert result is not None + assert result["success"] is True + + @patch('requests.Session.post') + def test \ No newline at end of file diff --git a/test/test_crossfit_booker_init.py b/test/test_crossfit_booker_init.py new file mode 100644 index 0000000..733b7cb --- /dev/null +++ b/test/test_crossfit_booker_init.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Unit tests for CrossFitBooker initialization +""" + +import pytest +import os +import sys +from unittest.mock import patch + +# Add the parent directory to the path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from crossfit_booker import CrossFitBooker + + +class TestCrossFitBookerInit: + """Test cases for CrossFitBooker initialization""" + + def test_init_success(self): + """Test successful initialization with all required env vars""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass', + 'EMAIL_FROM': 'from@test.com', + 'EMAIL_TO': 'to@test.com', + 'EMAIL_PASSWORD': 'email_pass', + 'TELEGRAM_TOKEN': 'telegram_token', + 'TELEGRAM_CHAT_ID': '12345' + }): + booker = CrossFitBooker() + assert booker.auth_token is None + assert booker.user_id is None + assert booker.session is not None + assert booker.notifier is not None + + def test_init_missing_credentials(self): + """Test initialization fails with missing credentials""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="Missing environment variables"): + CrossFitBooker() + + def test_init_partial_credentials(self): + """Test initialization fails with partial credentials""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user' + # Missing PASSWORD + }): + with pytest.raises(ValueError, match="Missing environment variables"): + CrossFitBooker() + + def test_init_with_optional_env_vars(self): + """Test initialization with optional environment variables""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass', + 'ENABLE_EMAIL_NOTIFICATIONS': 'false', + 'ENABLE_TELEGRAM_NOTIFICATIONS': 'false' + }): + booker = CrossFitBooker() + assert booker.notifier is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/test/test_crossfit_booker_sessions.py b/test/test_crossfit_booker_sessions.py new file mode 100644 index 0000000..8bc57a8 --- /dev/null +++ b/test/test_crossfit_booker_sessions.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +""" +Unit tests for CrossFitBooker session-related methods +""" + +import pytest +import os +import sys +from unittest.mock import patch, Mock, AsyncMock +from datetime import datetime, date, timedelta +import pytz +import asyncio + +# Add the parent directory to the path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from crossfit_booker import CrossFitBooker + + +class TestCrossFitBookerGetAvailableSessions: + """Test cases for get_available_sessions method""" + + def test_get_available_sessions_no_auth(self): + """Test get_available_sessions without authentication""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25)) + assert result is None + + @patch('requests.Session.post') + def test_get_available_sessions_success(self, mock_post): + """Test successful get_available_sessions""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "data": { + "activities_calendar": [ + {"id": "1", "name": "Test Session"} + ] + } + } + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token" + booker.user_id = "12345" + + result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25)) + + assert result is not None + assert result["success"] is True + + @patch('requests.Session.post') + def test_get_available_sessions_401_error(self, mock_post): + """Test get_available_sessions with 401 error""" + mock_response = Mock() + mock_response.status_code = 401 + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token" + booker.user_id = "12345" + + result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25)) + + assert result is None + + +class TestCrossFitBookerBookSession: + """Test cases for book_session method""" + + def test_book_session_no_auth(self): + """Test book_session without authentication""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + result = booker.book_session("session_123") + assert result is False + + @patch('requests.Session.post') + def test_book_session_success(self, mock_post): + """Test successful book_session""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True} + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token" + booker.user_id = "12345" + + result = booker.book_session("session_123") + + assert result is True + + @patch('requests.Session.post') + def test_book_session_api_failure(self, mock_post): + """Test book_session with API failure""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": False, "error": "Session full"} + + mock_post.return_value = mock_response + + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + booker.auth_token = "test_token" + booker.user_id = "12345" + + result = booker.book_session("session_123") + + assert result is False + + +class TestCrossFitBookerIsSessionBookable: + """Test cases for is_session_bookable method""" + + def test_is_session_bookable_can_join_true(self): + """Test session bookable with can_join=True""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = {"user_info": {"can_join": True}} + current_time = datetime.now(pytz.timezone("Europe/Paris")) + + result = booker.is_session_bookable(session, current_time) + assert result is True + + def test_is_session_bookable_booking_window_past(self): + """Test session bookable with booking window in past""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = { + "user_info": { + "can_join": False, + "unableToBookUntilDate": "01-01-2020", + "unableToBookUntilTime": "10:00" + } + } + current_time = datetime.now(pytz.timezone("Europe/Paris")) + + result = booker.is_session_bookable(session, current_time) + assert result is True + + def test_is_session_bookable_booking_window_future(self): + """Test session not bookable with booking window in future""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = { + "user_info": { + "can_join": False, + "unableToBookUntilDate": "01-01-2030", + "unableToBookUntilTime": "10:00" + } + } + current_time = datetime.now(pytz.timezone("Europe/Paris")) + + result = booker.is_session_bookable(session, current_time) + assert result is False + + +class TestCrossFitBookerRunBookingCycle: + """Test cases for run_booking_cycle method""" + + @patch('crossfit_booker.CrossFitBooker.get_available_sessions') + @patch('crossfit_booker.CrossFitBooker.is_session_bookable') + @patch('crossfit_booker.CrossFitBooker.matches_preferred_session') + @patch('crossfit_booker.CrossFitBooker.book_session') + async def test_run_booking_cycle_no_sessions(self, mock_book_session, mock_matches_preferred, mock_is_bookable, mock_get_sessions): + """Test run_booking_cycle with no available sessions""" + mock_get_sessions.return_value = {"success": False} + booker = CrossFitBooker() + await booker.run_booking_cycle(datetime.now(pytz.timezone("Europe/Paris"))) + mock_get_sessions.assert_called_once() + mock_book_session.assert_not_called() + + @patch('crossfit_booker.CrossFitBooker.get_available_sessions') + @patch('crossfit_booker.CrossFitBooker.is_session_bookable') + @patch('crossfit_booker.CrossFitBooker.matches_preferred_session') + @patch('crossfit_booker.CrossFitBooker.book_session') + async def test_run_booking_cycle_with_sessions(self, mock_book_session, mock_matches_preferred, mock_is_bookable, mock_get_sessions): + """Test run_booking_cycle with available sessions""" + # Use current date for the session to ensure it falls within 0-2 day window + current_time = datetime.now(pytz.timezone("Europe/Paris")) + session_date = current_time.date() + + mock_get_sessions.return_value = { + "success": True, + "data": { + "activities_calendar": [ + { + "id_activity_calendar": "1", + "name_activity": "CONDITIONING", + "start_timestamp": session_date.strftime("%Y-%m-%d") + " 18:30:00", + "user_info": {"can_join": True} + } + ] + } + } + mock_is_bookable.return_value = True + mock_matches_preferred.return_value = True + mock_book_session.return_value = True + + booker = CrossFitBooker() + await booker.run_booking_cycle(current_time) + + mock_get_sessions.assert_called_once() + mock_is_bookable.assert_called_once() + mock_matches_preferred.assert_called_once() + mock_book_session.assert_called_once() + assert mock_book_session.call_count == 1 + +class TestCrossFitBookerRun: + """Test cases for run method""" + + @patch('crossfit_booker.CrossFitBooker.login') + @patch('crossfit_booker.CrossFitBooker.run_booking_cycle') + async def test_run_auth_failure(self, mock_run_booking_cycle, mock_login): + """Test run with authentication failure""" + mock_login.return_value = False + booker = CrossFitBooker() + with patch.object(booker, 'run', new=booker.run) as mock_run: + await booker.run() + mock_login.assert_called_once() + mock_run_booking_cycle.assert_not_called() + + @patch('crossfit_booker.CrossFitBooker.login') + @patch('crossfit_booker.CrossFitBooker.run_booking_cycle') + @patch('crossfit_booker.CrossFitBooker.quit') + @patch('time.sleep') + @patch('datetime.datetime') + async def test_run_booking_outside_window(self, mock_datetime, mock_sleep, mock_quit, mock_run_booking_cycle, mock_login): + """Test run with booking outside window""" + mock_login.return_value = True + mock_quit.return_value = None # Prevent actual exit + + # Create a time outside the booking window (19:00) + tz = pytz.timezone("Europe/Paris") + mock_now = datetime(2025, 7, 25, 19, 0, tzinfo=tz) + mock_datetime.now.return_value = mock_now + + # Make sleep return immediately to allow one iteration, then break + call_count = 0 + def sleep_side_effect(seconds): + nonlocal call_count + call_count += 1 + if call_count >= 1: + # Break the loop after first sleep + raise KeyboardInterrupt("Test complete") + return None + + mock_sleep.side_effect = sleep_side_effect + + booker = CrossFitBooker() + + try: + await booker.run() + except KeyboardInterrupt: + pass # Expected to break the loop + + # Verify login was called + mock_login.assert_called_once() + + # Verify run_booking_cycle was NOT called since we're outside the booking window + mock_run_booking_cycle.assert_not_called() + + # Verify quit was called (due to KeyboardInterrupt) + mock_quit.assert_called_once() + +class TestCrossFitBookerQuit: + """Test cases for quit method""" + + def test_quit(self): + """Test quit method""" + booker = CrossFitBooker() + with patch('sys.exit') as mock_exit: + with pytest.raises(SystemExit) as excinfo: + booker.quit() + assert excinfo.value.code == 0 +class TestCrossFitBookerMatchesPreferredSession: + """Test cases for matches_preferred_session method""" + + def test_matches_preferred_session_exact_match(self): + """Test exact match with preferred session""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = { + "start_timestamp": "2025-07-30 18:30:00", + "name_activity": "CONDITIONING" + } + current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris")) + + # Mock PREFERRED_SESSIONS + with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]): + result = booker.matches_preferred_session(session, current_time) + assert result is True + + def test_matches_preferred_session_fuzzy_match(self): + """Test fuzzy match with preferred session""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = { + "start_timestamp": "2025-07-30 18:30:00", + "name_activity": "CONDITIONING WORKOUT" + } + current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris")) + + # Mock PREFERRED_SESSIONS + with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]): + result = booker.matches_preferred_session(session, current_time) + assert result is True + + def test_matches_preferred_session_no_match(self): + """Test no match with preferred session""" + with patch.dict(os.environ, { + 'CROSSFIT_USERNAME': 'test_user', + 'CROSSFIT_PASSWORD': 'test_pass' + }): + booker = CrossFitBooker() + session = { + "start_timestamp": "2025-07-30 18:30:00", + "name_activity": "YOGA" + } + current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris")) + + # Mock PREFERRED_SESSIONS + with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]): + result = booker.matches_preferred_session(session, current_time) + assert result is False \ No newline at end of file