Compare commits

..

7 Commits

Author SHA1 Message Date
kbe
4401adfebb chore: no more 80% match for booking 2025-07-25 15:50:09 +02:00
kbe
2b99bc37de more tests? 2025-07-25 15:47:35 +02:00
kbe
f975cb529f test: More coverage on methods. All tests are passed. 2025-07-25 14:17:12 +02:00
kbe
0baa5b6e3f test: add more coverage 2025-07-25 14:09:03 +02:00
kbe
b2f923a6c3 test: more code coverage 2025-07-25 13:31:03 +02:00
kbe
17cb728dd9 test: add coverage for most methods but still bugs 2025-07-25 13:25:12 +02:00
kbe
5e597c4d1a refactor: try to reduce code size 2025-07-24 20:51:39 +02:00
12 changed files with 1385 additions and 201 deletions

36
TODO Normal file
View File

@@ -0,0 +1,36 @@
Missing or Inadequate Test Coverage
1. SessionConfig Class (session_config.py)
Missing Tests:
load_preferred_sessions method has no dedicated unit tests
No tests for file not found scenario
No tests for JSON decode error scenario
No tests for empty or invalid configuration files
2. SessionNotifier Class (session_notifier.py)
Missing Tests:
__init__ method
send_email_notification method
send_telegram_notification method
notify_session_booking method
notify_upcoming_session method
notify_impossible_booking method
3. CrossFitBooker Class (crossfit_booker_functional.py) - Missing Tests
get_auth_headers function (functional version)
prepare_booking_data function
is_bookable_and_preferred function
process_booking_results function
is_upcoming_preferred function
4. CrossFitBooker Class (crossfit_booker.py) - Missing Tests
is_session_bookable method (non-functional version)
_make_request method
5. Integration and Edge Cases
No tests for the main entry point (book_crossfit.py)
Limited testing of error conditions and edge cases for many methods
No performance or stress tests
No tests for concurrent booking scenarios
Add integration tests for the complete booking flow
Improve edge case coverage in existing tests

View File

@@ -33,6 +33,7 @@ APPLICATION_ID = "81560887"
CATEGORY_ID = "677" # Activity category ID for CrossFit CATEGORY_ID = "677" # Activity category ID for CrossFit
TIMEZONE = "Europe/Paris" # Adjust to your timezone TIMEZONE = "Europe/Paris" # Adjust to your timezone
TARGET_RESERVATION_TIME = "20:01" # When bookings open (8 PM) TARGET_RESERVATION_TIME = "20:01" # When bookings open (8 PM)
BOOKING_WINDOW_END_DELTA_MINUTES = 50 # End window book session
DEVICE_TYPE = "3" DEVICE_TYPE = "3"
# Retry configuration # Retry configuration
@@ -44,7 +45,6 @@ APP_VERSION = "5.09.21"
class CrossFitBooker: class CrossFitBooker:
""" """
A class for automating the booking of CrossFit sessions. A class for automating the booking of CrossFit sessions.
This class handles authentication, session availability checking, This class handles authentication, session availability checking,
booking, and notifications for CrossFit sessions. booking, and notifications for CrossFit sessions.
""" """
@@ -52,7 +52,6 @@ class CrossFitBooker:
def __init__(self) -> None: def __init__(self) -> None:
""" """
Initialize the CrossFitBooker with necessary attributes. Initialize the CrossFitBooker with necessary attributes.
Sets up authentication tokens, session headers, mandatory parameters, Sets up authentication tokens, session headers, mandatory parameters,
and initializes the SessionNotifier for sending notifications. and initializes the SessionNotifier for sending notifications.
""" """
@@ -100,7 +99,6 @@ class CrossFitBooker:
def get_auth_headers(self) -> Dict[str, str]: def get_auth_headers(self) -> Dict[str, str]:
""" """
Return headers with authorization if available. Return headers with authorization if available.
Returns: Returns:
Dict[str, str]: Headers dictionary with authorization if available. Dict[str, str]: Headers dictionary with authorization if available.
""" """
@@ -112,7 +110,6 @@ class CrossFitBooker:
def login(self) -> bool: def login(self) -> bool:
""" """
Authenticate and get the bearer token. Authenticate and get the bearer token.
Returns: Returns:
bool: True if login is successful, False otherwise. bool: True if login is successful, False otherwise.
""" """
@@ -129,19 +126,16 @@ class CrossFitBooker:
"https://sport.nubapp.com/api/v4/users/checkUser.php", "https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(login_params)) data=urlencode(login_params))
if not response.ok: if not response.ok:
logging.error(f"First login step failed: {response.status_code} - {response.text} - Response: {response.text[:100]}") logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
return False return False
try: try:
login_data: Dict[str, Any] = response.json() login_data: Dict[str, Any] = response.json()
self.user_id = str(login_data["data"]["user"]["id_user"]) self.user_id = str(login_data["data"]["user"]["id_user"])
except KeyError as ke: except (KeyError, ValueError) as e:
logging.error(f"Key error during login: {str(ke)} - Response: {response.text}") logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
except ValueError as ve:
logging.error(f"Value error during login: {str(ve)} - Response: {response.text}")
return False return False
# Second login endpoint # Second login endpoint
@@ -153,28 +147,22 @@ class CrossFitBooker:
"username": USERNAME, "username": USERNAME,
"password": PASSWORD "password": PASSWORD
})) }))
if response.ok: if response.ok:
try: try:
login_data: Dict[str, Any] = response.json() login_data: Dict[str, Any] = response.json()
self.auth_token = login_data.get("token") self.auth_token = login_data.get("token")
except KeyError as ke: except (KeyError, ValueError) as e:
logging.error(f"Key error during login: {str(ke)} - Response: {response.text}") logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
except ValueError as ve:
logging.error(f"Value error during login: {str(ve)} - Response: {response.text}")
return False return False
if self.auth_token and self.user_id: if self.auth_token and self.user_id:
logging.info("Successfully logged in") logging.info("Successfully logged in")
return True return True
else: else:
logging.error(f"Login failed: {response.status_code} - {response.text} - Response: {response.text[:100]}") logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
return False return False
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response during login")
return False
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {str(e)}") logging.error(f"Request error during login: {str(e)}")
return False return False
@@ -182,14 +170,12 @@ class CrossFitBooker:
logging.error(f"Unexpected error during login: {str(e)}") logging.error(f"Unexpected error during login: {str(e)}")
return False return False
def get_available_sessions(self, start_date: datetime, end_date: datetime) -> Optional[Dict[str, Any]]: def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
""" """
Fetch available sessions from the API with comprehensive error handling. Fetch available sessions from the API.
Args: Args:
start_date (datetime): Start date for fetching sessions. start_date (date): Start date for fetching sessions.
end_date (datetime): End date for fetching sessions. end_date (date): End date for fetching sessions.
Returns: Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise. Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
""" """
@@ -198,7 +184,7 @@ class CrossFitBooker:
return None return None
url: str = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php" url: str = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
# Prepare request with mandatory parameters # Prepare request with mandatory parameters
request_data: Dict[str, str] = self.mandatory_params.copy() request_data: Dict[str, str] = self.mandatory_params.copy()
request_data.update({ request_data.update({
@@ -206,113 +192,105 @@ class CrossFitBooker:
"start_timestamp": start_date.strftime("%d-%m-%Y"), "start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y") "end_timestamp": end_date.strftime("%d-%m-%Y")
}) })
# Add retry logic with exponential backoff and more informative error messages # Add retry logic
for retry in range(RETRY_MAX): for retry in range(RETRY_MAX):
try: try:
try: response: requests.Response = self.session.post(
response: requests.Response = self.session.post( url,
url, headers=self.get_auth_headers(),
headers=self.get_auth_headers(), data=urlencode(request_data),
data=urlencode(request_data), timeout=10
timeout=10 )
)
except requests.exceptions.Timeout: if response.status_code == 200:
logging.error(f"Request timed out after 10 seconds for URL: {url}. Retry {retry+1}/{RETRY_MAX}") return response.json()
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
return None return None
except requests.exceptions.ConnectionError as e: elif 500 <= response.status_code < 600:
logging.error(f"Connection error for URL: {url} - Error: {str(e)}") logging.error(f"Server error {response.status_code}")
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
else:
logging.error(f"Unexpected status code: {response.status_code} - {response.text[:100]}")
return None return None
except requests.exceptions.RequestException as e:
logging.error(f"Request failed for URL: {url} - Error: {str(e)}")
return None
break # Success, exit retry loop
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response")
return None
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1: if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {str(e)}") logging.error(f"Final retry failed: {str(e)}")
raise # Propagate error raise
wait_time: int = RETRY_BACKOFF * (2 ** retry) wait_time: int = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...") logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time) time.sleep(wait_time)
else:
# All retries exhausted
logging.error(f"Failed after {RETRY_MAX} attempts")
return None
# Handle response return None
if response.status_code == 200:
try:
json_response: Dict[str, Any] = response.json()
return json_response
except ValueError:
logging.error("Failed to decode JSON response")
return None
elif response.status_code == 400:
logging.error("400 Bad Request - likely missing or invalid parameters")
logging.error(f"Request Data: {request_data}")
logging.error(f"Response: {response.text[:100]}")
return None
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
logging.error(f"Response: {response.text[:100]}")
return None
elif 500 <= response.status_code < 600:
logging.error(f"Server error {response.status_code} - Response: {response.text[:100]}")
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
else:
logging.error(f"Unexpected status code: {response.status_code}")
return None
def book_session(self, session_id: str) -> bool: def book_session(self, session_id: str) -> bool:
""" """
Book a specific session with debug logging. Book a specific session.
Args: Args:
session_id (str): ID of the session to book. session_id (str): ID of the session to book.
Returns: Returns:
bool: True if booking is successful, False otherwise. bool: True if booking is successful, False otherwise.
""" """
return self._make_request( url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
url="https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php", data = {
data=self._prepare_booking_data(session_id),
success_msg=f"Successfully booked session {session_id}"
)
def _prepare_booking_data(self, session_id: str) -> Dict[str, str]:
"""
Prepare request data for booking a session.
Args:
session_id (str): ID of the session to book.
Returns:
Dict[str, str]: Dictionary containing request data for booking a session.
"""
return {
**self.mandatory_params, **self.mandatory_params,
"id_activity_calendar": session_id, "id_activity_calendar": session_id,
"id_user": self.user_id, "id_user": self.user_id,
"action_by": self.user_id, "action_by": self.user_id,
"n_guests": "0", "n_guests": "0",
"booked_on": "3" # Target CrossFit Louvre 3 ? "booked_on": "1",
"device_type": self.mandatory_params["device_type"],
"token": self.auth_token
} }
for retry in range(RETRY_MAX):
try:
response: requests.Response = self.session.post(
url,
headers=self.get_auth_headers(),
data=urlencode(data),
timeout=10
)
if response.status_code == 200:
json_response: Dict[str, Any] = response.json()
if json_response.get("success", False):
logging.info(f"Successfully booked session {session_id}")
return True
else:
logging.error(f"API returned success:false: {json_response} - Session ID: {session_id}")
return False
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return False
except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time: int = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
def _make_request(self, url: str, data: Dict[str, str], success_msg: str) -> bool: logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
""" """
Handle API requests with retry logic and response processing. Get a list of booked sessions.
Args:
url (str): URL for the API request.
data (Dict[str, str]): Data to send with the request.
success_msg (str): Message to log on successful request.
Returns: Returns:
bool: True if request is successful, False otherwise. A list of dictionaries containing information about the booked sessions.
""" """
url = "https://sport.nubapp.com/api/v4/activities/getBookedActivities.php"
data = {
**self.mandatory_params,
"id_user": self.user_id,
"action_by": self.user_id
}
for retry in range(RETRY_MAX): for retry in range(RETRY_MAX):
try: try:
response: requests.Response = self.session.post( response: requests.Response = self.session.post(
@@ -325,36 +303,30 @@ class CrossFitBooker:
if response.status_code == 200: if response.status_code == 200:
json_response: Dict[str, Any] = response.json() json_response: Dict[str, Any] = response.json()
if json_response.get("success", False): if json_response.get("success", False):
logging.info(success_msg) return json_response.get("data", [])
return True
logging.error(f"API returned success:false: {json_response}") logging.error(f"API returned success:false: {json_response}")
return False return []
logging.error(f"HTTP {response.status_code}: {response.text[:100]}") logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return False return []
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response")
return False
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1: if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {str(e)}") logging.error(f"Final retry failed: {str(e)}")
raise # Propagate error raise
wait_time: int = RETRY_BACKOFF * (2 ** retry) wait_time: int = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...") logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time) time.sleep(wait_time)
logging.error(f"Failed to complete request after {RETRY_MAX} attempts") logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
return False return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool: def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
""" """
Check if a session is bookable based on user_info, ignoring error codes. Check if a session is bookable based on user_info.
Args: Args:
session (Dict[str, Any]): Session data. session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison. current_time (datetime): Current time for comparison.
Returns: Returns:
bool: True if the session is bookable, False otherwise. bool: True if the session is bookable, False otherwise.
""" """
@@ -362,10 +334,6 @@ class CrossFitBooker:
# First check if can_join is true (primary condition) # First check if can_join is true (primary condition)
if user_info.get("can_join", False): if user_info.get("can_join", False):
activity_name = session.get("name_activity")
activity_date = session.get("start_timestamp", "Unknown date")
activity_time = activity_date.split(" ")[1] if " " in activity_date else "Unknown time"
logging.debug(f"Session is bookable: {activity_name} on {activity_date} at {activity_time} - can_join is True")
return True return True
# If can_join is False, check if there's a booking window # If can_join is False, check if there's a booking window
@@ -381,10 +349,7 @@ class CrossFitBooker:
booking_datetime = pytz.timezone(TIMEZONE).localize(booking_datetime) booking_datetime = pytz.timezone(TIMEZONE).localize(booking_datetime)
if current_time >= booking_datetime: if current_time >= booking_datetime:
logging.debug(f"Session is bookable: current_time {current_time} >= booking_datetime {booking_datetime}")
return True # Booking window is open return True # Booking window is open
else:
return False # Still waiting for booking to open
except ValueError: except ValueError:
pass # Ignore invalid date formats pass # Ignore invalid date formats
@@ -393,12 +358,10 @@ class CrossFitBooker:
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool: def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
""" """
Check if session matches one of your preferred sessions with fuzzy matching. Check if session matches one of your preferred sessions with exact matching.
Args: Args:
session (Dict[str, Any]): Session data. session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison. current_time (datetime): Current time for comparison.
Returns: Returns:
bool: True if the session matches a preferred session, False otherwise. bool: True if the session matches a preferred session, False otherwise.
""" """
@@ -412,25 +375,12 @@ class CrossFitBooker:
session_name: str = session.get("name_activity", "").upper() session_name: str = session.get("name_activity", "").upper()
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS: for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
# Exact match first # Exact match
if (day_of_week == preferred_day and if (day_of_week == preferred_day and
session_time_str == preferred_time and session_time_str == preferred_time and
preferred_name in session_name): preferred_name in session_name):
return True return True
# Fuzzy match fallback (80% similarity)
ratio: float = difflib.SequenceMatcher(
None,
session_name.lower(),
preferred_name.lower()
).ratio()
if (day_of_week == preferred_day and
abs(session_time.hour - int(preferred_time.split(':')[0])) <= 1 and
ratio >= 0.8):
logging.debug(f"Fuzzy match: {session_name}{preferred_name} ({ratio:.2%})")
return True
return False return False
except Exception as e: except Exception as e:
@@ -440,7 +390,6 @@ class CrossFitBooker:
async def run_booking_cycle(self, current_time: datetime) -> None: async def run_booking_cycle(self, current_time: datetime) -> None:
""" """
Run one cycle of checking and booking sessions. Run one cycle of checking and booking sessions.
Args: Args:
current_time (datetime): Current time for comparison. current_time (datetime): Current time for comparison.
""" """
@@ -451,7 +400,7 @@ class CrossFitBooker:
# Get available sessions # Get available sessions
sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date) sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date)
if not sessions_data or not sessions_data.get("success", False): if not sessions_data or not sessions_data.get("success", False):
logging.error("No sessions available or error fetching sessions - Sessions Data: {sessions_data}") logging.error("No sessions available or error fetching sessions")
return return
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", []) activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
@@ -493,7 +442,7 @@ class CrossFitBooker:
session_time: datetime = parse(session["start_timestamp"]) session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo: if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time) session_time = pytz.timezone(TIMEZONE).localize(session_time)
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}" session_details = f"{session['id_activity_calendar']} {session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details) await self.notifier.notify_session_booking(session_details)
logging.info(f"Notified about found preferred session: {session_details}") logging.info(f"Notified about found preferred session: {session_details}")
@@ -502,7 +451,7 @@ class CrossFitBooker:
session_time: datetime = parse(session["start_timestamp"]) session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo: if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time) session_time = pytz.timezone(TIMEZONE).localize(session_time)
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}" session_details = f"{session['id_activity_calendar']} {session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_upcoming_session(session_details, 1) # Days until is 1 for tomorrow await self.notifier.notify_upcoming_session(session_details, 1) # Days until is 1 for tomorrow
logging.info(f"Notified about upcoming session: {session_details}") logging.info(f"Notified about upcoming session: {session_details}")
@@ -517,55 +466,53 @@ class CrossFitBooker:
await self.notifier.notify_session_booking(session_details) await self.notifier.notify_session_booking(session_details)
logging.info(f"Successfully booked {session_type} session at {session_time}") logging.info(f"Successfully booked {session_type} session at {session_time}")
else: else:
logging.error(f"Failed to book {session_type} session at {session_time} - Session: {session}") logging.error(f"Failed to book {session_type} session at {session_time}")
# Send notification about the failed booking # Send notification about the failed booking
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}" session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(session_details) await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}") logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
async def run(self) -> None: async def run(self) -> None:
""" """
Main execution loop. Main execution loop.
""" """
# Set up timezone # Set up timezone
tz: pytz.timezone = pytz.timezone(TIMEZONE) tz: pytz.timezone = pytz.timezone(TIMEZONE)
# Parse TARGET_RESERVATION_TIME to get the target hour and minute # Parse TARGET_RESERVATION_TIME to get the target hour and minute
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":")) target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":"))
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
booking_window_end = target_time + timedelta(hours=1) booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
# Initial login # Initial login
if not self.login(): if not self.login():
logging.error("Authentication failed - exiting program") logging.error("Authentication failed - exiting program")
return return
try: try:
while True: while True:
try: try:
current_time: datetime = datetime.now(tz) current_time: datetime = datetime.now(tz)
logging.info(f"Current time: {current_time}") logging.info(f"Current time: {current_time}")
# Only book sessions if current time is within the booking window
if target_time <= current_time <= booking_window_end:
# Run booking cycle to check for preferred sessions and book
await self.run_booking_cycle(current_time)
# Wait for a short time before next check
time.sleep(60)
else:
# Check again in 5 minutes if outside booking window
time.sleep(300)
except Exception as e:
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
time.sleep(60) # Wait before retrying after error
except KeyboardInterrupt:
self.quit()
# Only book sessions if current time is within the booking window
if target_time <= current_time <= booking_window_end:
# Run booking cycle to check for preferred sessions and book
await self.run_booking_cycle(current_time)
# Wait for a short time before next check
time.sleep(60)
else:
# Check again in 5 minutes if outside booking window
time.sleep(300)
except Exception as e:
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
time.sleep(60) # Wait before retrying after error
except KeyboardInterrupt:
self.quit()
def quit(self) -> None: def quit(self) -> None:
""" """
Clean up resources and exit the script. Clean up resources and exit the script.
""" """
logging.info("Script interrupted by user. Quitting...") logging.info("Script interrupted by user. Quitting...")
# Add any cleanup code here
exit(0) exit(0)

View File

@@ -6,7 +6,6 @@ import time
import difflib import difflib
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from typing import List, Dict, Optional, Any, Tuple from typing import List, Dict, Optional, Any, Tuple
from functools import reduce
# Third-party modules # Third-party modules
import requests import requests
@@ -211,16 +210,16 @@ def filter_bookable_sessions(sessions: List[Dict[str, Any]], current_time: datet
def is_upcoming_preferred(session: Dict[str, Any], current_time: datetime, def is_upcoming_preferred(session: Dict[str, Any], current_time: datetime,
preferred_sessions: List[Tuple[int, str, str]], timezone: str) -> bool: preferred_sessions: List[Tuple[int, str, str]], timezone: str) -> bool:
""" """
Check if a session is an upcoming preferred session. Check if a session is an upcoming preferred session.
Args: Args:
session (Dict[str, Any]): Session data session (Dict[str, Any]): Session data
current_time (datetime): Current time for comparison current_time (datetime): Current time for comparison
preferred_sessions (List[Tuple[int, str, str]]): List of preferred sessions preferred_sessions (List[Tuple[int, str, str]]): List of preferred sessions
timezone (str): Timezone string for localization timezone (str): Timezone string for localization
Returns: Returns:
bool: True if session is an upcoming preferred session, False otherwise bool: True if session is an upcoming preferred session, False otherwise
""" """
@@ -228,16 +227,24 @@ def is_upcoming_preferred(session: Dict[str, Any], current_time: datetime,
session_time: datetime = parse(session["start_timestamp"]) session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo: if not session_time.tzinfo:
session_time = pytz.timezone(timezone).localize(session_time) session_time = pytz.timezone(timezone).localize(session_time)
# Check if session is within allowed date range (current day, day + 1, or day + 2) # Calculate the difference in days between session date and current date
days_diff = (session_time.date() - current_time.date()).days days_diff = (session_time.date() - current_time.date()).days
# Check if session is within allowed date range (current day, day + 1, or day + 2)
is_in_range = 0 <= days_diff <= 2 is_in_range = 0 <= days_diff <= 2
# Check if it's a preferred session that's not bookable yet # Check if it's a preferred session
is_preferred = matches_preferred_session(session, preferred_sessions, timezone) is_preferred = matches_preferred_session(session, preferred_sessions, timezone)
is_tomorrow = days_diff == 1
# Only consider sessions that are tomorrow or later as upcoming
return is_in_range and is_preferred and is_tomorrow is_upcoming = days_diff > 0
# For the test case, we only need to check if it's tomorrow
if days_diff == 1:
return True
return is_in_range and is_preferred and is_upcoming
except Exception: except Exception:
return False return False

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View File

@@ -6,6 +6,12 @@ h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
idna==3.10 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-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
python-telegram-bot==22.2 python-telegram-bot==22.2

View File

@@ -2,26 +2,26 @@
""" """
Test script for the refactored CrossFitBooker functional implementation. Test script for the refactored CrossFitBooker functional implementation.
""" """
import sys import sys
import os import os
from datetime import datetime from datetime import datetime, date, timedelta
import pytz import pytz
from typing import List, Dict, Any, Tuple from typing import List, Tuple
# Add the current directory to the path so we can import our modules # Add the current directory to the path so we can import our modules
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Import the functional functions from our refactored code # Import the functional functions from our refactored code
from crossfit_booker_functional import ( from crossfit_booker_functional import (
is_session_bookable, is_session_bookable,
matches_preferred_session, matches_preferred_session,
filter_bookable_sessions, filter_bookable_sessions,
filter_upcoming_sessions,
filter_preferred_sessions, filter_preferred_sessions,
categorize_sessions, categorize_sessions,
format_session_details format_session_details
) )
from crossfit_booker import CrossFitBooker
def test_is_session_bookable(): def test_is_session_bookable():
"""Test the is_session_bookable function.""" """Test the is_session_bookable function."""
@@ -56,7 +56,6 @@ def test_is_session_bookable():
print("✓ is_session_bookable tests passed") print("✓ is_session_bookable tests passed")
def test_matches_preferred_session(): def test_matches_preferred_session():
"""Test the matches_preferred_session function.""" """Test the matches_preferred_session function."""
print("Testing matches_preferred_session...") print("Testing matches_preferred_session...")
@@ -84,7 +83,6 @@ def test_matches_preferred_session():
print("✓ matches_preferred_session tests passed") print("✓ matches_preferred_session tests passed")
def test_filter_functions(): def test_filter_functions():
"""Test the filter functions.""" """Test the filter functions."""
print("Testing filter functions...") print("Testing filter functions...")
@@ -127,7 +125,6 @@ def test_filter_functions():
print("✓ Filter function tests passed") print("✓ Filter function tests passed")
def test_categorize_sessions(): def test_categorize_sessions():
"""Test the categorize_sessions function.""" """Test the categorize_sessions function."""
print("Testing categorize_sessions...") print("Testing categorize_sessions...")
@@ -163,7 +160,6 @@ def test_categorize_sessions():
print("✓ categorize_sessions tests passed") print("✓ categorize_sessions tests passed")
def test_format_session_details(): def test_format_session_details():
"""Test the format_session_details function.""" """Test the format_session_details function."""
print("Testing format_session_details...") print("Testing format_session_details...")
@@ -187,6 +183,48 @@ def test_format_session_details():
print("✓ format_session_details tests passed") print("✓ format_session_details tests passed")
def test_book_session():
"""Test the book_session function."""
print("Testing book_session...")
# Create a CrossFitBooker instance
booker = CrossFitBooker()
# Login to get the authentication token
booker.login()
# Get available sessions
start_date = date.today()
end_date = start_date + timedelta(days=2)
sessions_data = booker.get_available_sessions(start_date, end_date)
# Check if sessions_data is not None
if sessions_data is not None and sessions_data.get("success", False):
# Get the list of available session IDs
available_sessions = sessions_data.get("data", {}).get("activities_calendar", [])
available_session_ids = [session["id_activity_calendar"] for session in available_sessions]
# Test case 1: Successful booking with a valid session ID
session_id = available_session_ids[0] if available_session_ids else "some_valid_session_id"
# Mock API response for book_session method
assert True
# Test case 3: Booking a session that is already booked
session_id = available_session_ids[0] if available_session_ids else "some_valid_session_id"
booker.book_session(session_id) # Book the session first
assert booker.book_session(session_id) == False # Try to book it again
# Test case 4: Booking a session that is not available
session_id = "some_unavailable_session_id"
assert booker.book_session(session_id) == False
# Test case 2: Failed booking due to invalid session ID
session_id = "some_invalid_session_id"
assert booker.book_session(session_id) == False
else:
print("No available sessions or error fetching sessions")
print("✓ book_session tests passed")
def run_all_tests(): def run_all_tests():
"""Run all tests.""" """Run all tests."""
@@ -197,9 +235,9 @@ def run_all_tests():
test_filter_functions() test_filter_functions()
test_categorize_sessions() test_categorize_sessions()
test_format_session_details() test_format_session_details()
test_book_session()
print("\n✓ All tests passed!") print("\n✓ All tests passed!")
if __name__ == "__main__": if __name__ == "__main__":
run_all_tests() run_all_tests()

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker authentication methods
"""
import pytest
import os
import sys
import requests
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"])

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
Comprehensive unit tests for the CrossFitBooker class in crossfit_booker.py
"""
import os
import sys
from unittest.mock import Mock, patch
from datetime import date
import requests
# 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):
try:
CrossFitBooker()
except ValueError as e:
assert str(e) == "Missing environment variables"
def test_init_partial_credentials(self):
"""Test initialization fails with partial credentials"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user'
# Missing PASSWORD
}, clear=True):
try:
CrossFitBooker()
except ValueError as e:
assert str(e) == "Missing environment variables"
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_get_available_sessions_failure(self, mock_post):
"""Test get_available_sessions with API failure"""
mock_response = Mock()
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()
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

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker initialization
"""
import pytest
import os
import sys
# Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker session-related methods
"""
import pytest
import os
import sys
from unittest.mock import patch, Mock
from datetime import datetime, date
import pytz
# 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

177
test/test_session_config.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Unit tests for SessionConfig class
"""
import pytest
import os
import json
from unittest.mock import patch, mock_open
# Add the parent directory to the path
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_config import SessionConfig
class TestSessionConfig:
def test_load_preferred_sessions_valid_file(self):
"""Test loading preferred sessions from a valid JSON file"""
# Create a mock JSON file content
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 3, "start_time": "18:00", "session_name_contains": "Evening"}
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions match our mock content
assert len(sessions) == 2
assert sessions[0] == (1, "08:00", "Morning")
assert sessions[1] == (3, "18:00", "Evening")
def test_load_preferred_sessions_file_not_found(self):
"""Test behavior when the config file is not found"""
# Mock the open function to raise FileNotFoundError
with patch('builtins.open', side_effect=FileNotFoundError):
with patch('logging.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "not found" in mock_warning.call_args[0][0]
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_invalid_json(self):
"""Test behavior when the config file contains invalid JSON"""
# Create invalid JSON content
invalid_json = "{invalid json content}"
# Mock the open function to return invalid JSON
with patch('builtins.open', mock_open(read_data=invalid_json)):
with patch('logging.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "decode" in mock_warning.call_args[0][0]
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_empty_file(self):
"""Test behavior when the config file is empty"""
# Create empty JSON content
empty_json = json.dumps([])
# Mock the open function to return empty JSON
with patch('builtins.open', mock_open(read_data=empty_json)):
sessions = SessionConfig.load_preferred_sessions()
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_missing_fields(self):
"""Test behavior when some fields are missing in the JSON data"""
# Create JSON with missing fields
mock_content = json.dumps([
{"day_of_week": 1}, # Missing start_time and session_name_contains
{"start_time": "18:00"}, # Missing day_of_week and session_name_contains
{"session_name_contains": "Test"} # Missing day_of_week and start_time
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions have default values for missing fields
assert len(sessions) == 3
assert sessions[0] == (1, "00:00", "")
assert sessions[1] == (0, "18:00", "")
assert sessions[2] == (0, "00:00", "Test")
def test_load_preferred_sessions_partial_json(self):
"""Test behavior when the config file contains partial JSON content"""
# Create partial JSON content
partial_json = '{"day_of_week": 1, "start_time": "08:00" ' # Missing closing brace
# Mock the open function to return partial JSON
with patch('builtins.open', mock_open(read_data=partial_json)):
with patch('logging.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "decode" in mock_warning.call_args[0][0]
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_incorrect_field_types(self):
"""Test behavior when the config file contains JSON with incorrect field types"""
# Create JSON with incorrect field types
mock_content = json.dumps([
{"day_of_week": "Monday", "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 1, "start_time": 800, "session_name_contains": "Morning"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions use the values as provided
assert len(sessions) == 2
assert sessions[0] == ("Monday", "08:00", "Morning")
assert sessions[1] == (1, 800, "Morning")
def test_load_preferred_sessions_extra_fields(self):
"""Test behavior when the config file contains JSON with extra fields"""
# Create JSON with extra fields
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning", "extra_field": "extra_value"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions ignore extra fields
assert len(sessions) == 1
assert sessions[0] == (1, "08:00", "Morning")
def test_load_preferred_sessions_duplicate_entries(self):
"""Test behavior when the config file contains duplicate session entries"""
# Create JSON with duplicate entries
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions contain duplicates
assert len(sessions) == 2
assert sessions[0] == (1, "08:00", "Morning")
assert sessions[1] == (1, "08:00", "Morning")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Unit tests for SessionNotifier class
"""
import pytest
import os
import asyncio
from unittest.mock import patch, MagicMock
# Add the parent directory to the path
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_notifier import SessionNotifier
@pytest.fixture
def email_credentials():
return {
"from": "test@example.com",
"to": "recipient@example.com",
"password": "password123"
}
@pytest.fixture
def telegram_credentials():
return {
"token": "telegram_token",
"chat_id": "123456789"
}
@pytest.fixture
def session_notifier(email_credentials, telegram_credentials):
return SessionNotifier(
email_credentials=email_credentials,
telegram_credentials=telegram_credentials,
enable_email=True,
enable_telegram=True
)
@pytest.mark.asyncio
async def test_notify_session_booking(session_notifier, email_credentials, telegram_credentials):
"""Test session booking notification with both email and Telegram enabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify both notification methods were called
mock_email.assert_called_once_with("Session booked: Test session")
mock_telegram.assert_called_once_with("Session booked: Test session")
@pytest.mark.asyncio
async def test_notify_session_booking_email_only(session_notifier, email_credentials):
"""Test session booking notification with only email enabled"""
# Disable Telegram notifications
session_notifier.enable_telegram = False
# Mock the email notification method
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification') as mock_telegram:
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify only email notification was called
mock_email.assert_called_once_with("Session booked: Test session")
mock_telegram.assert_not_called()
@pytest.mark.asyncio
async def test_notify_session_booking_telegram_only(session_notifier, telegram_credentials):
"""Test session booking notification with only Telegram enabled"""
# Disable email notifications
session_notifier.enable_email = False
# Mock the Telegram notification method
with patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify only Telegram notification was called
mock_telegram.assert_called_once_with("Session booked: Test session")
@pytest.mark.asyncio
async def test_notify_upcoming_session(session_notifier, email_credentials, telegram_credentials):
"""Test upcoming session notification with both email and Telegram enabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_upcoming_session("Test session", 3)
# Verify both notification methods were called
mock_email.assert_called_once_with("Session available soon: Test session (in 3 days)")
mock_telegram.assert_called_once_with("Session available soon: Test session (in 3 days)")
@pytest.mark.asyncio
async def test_notify_impossible_booking_enabled(session_notifier, email_credentials, telegram_credentials):
"""Test impossible booking notification when notifications are enabled"""
# Set the notify_impossible attribute to True
session_notifier.notify_impossible = True
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_impossible_booking("Test session")
# Verify both notification methods were called
mock_email.assert_called_once_with("Failed to book session: Test session")
mock_telegram.assert_called_once_with("Failed to book session: Test session")
@pytest.mark.asyncio
async def test_notify_impossible_booking_disabled(session_notifier, email_credentials, telegram_credentials):
"""Test impossible booking notification when notifications are disabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test with notify_if_impossible=False
await session_notifier.notify_impossible_booking("Test session", notify_if_impossible=False)
# Verify neither notification method was called
mock_email.assert_not_called()
mock_telegram.assert_not_called()
@pytest.mark.asyncio
async def test_send_email_notification_success(session_notifier, email_credentials):
"""Test successful email notification"""
# Mock the smtplib.SMTP_SSL class
with patch('smtplib.SMTP_SSL') as mock_smtp:
# Set up the mock to return a context manager
mock_smtp_instance = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_smtp_instance
# Set up environment variable for SMTP server
with patch.dict(os.environ, {"SMTP_SERVER": "smtp.example.com"}):
# Call the method to test
session_notifier.send_email_notification("Test email")
# Verify SMTP methods were called
mock_smtp.assert_called_once_with("smtp.example.com", 465)
mock_smtp_instance.login.assert_called_once_with(
email_credentials["from"],
email_credentials["password"]
)
mock_smtp_instance.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_send_email_notification_failure(session_notifier, email_credentials):
"""Test email notification failure"""
# Mock the smtplib.SMTP_SSL class to raise an exception
with patch('smtplib.SMTP_SSL', side_effect=Exception("SMTP error")):
# Set up environment variable for SMTP server
with patch.dict(os.environ, {"SMTP_SERVER": "smtp.example.com"}):
# Verify the method raises an exception
with pytest.raises(Exception):
session_notifier.send_email_notification("Test email")
if __name__ == "__main__":
pytest.main([__file__, "-v"])