Compare commits

...

3 Commits

Author SHA1 Message Date
kbe
a7f9e6bacd Fixed failing tests in test_crossfit_booker_sessions.py
Fixed test_run_auth_failure by patching the correct method (CrossFitBooker.login) and calling the correct method (booker.login())
Fixed test_run_booking_outside_window by patching the correct method (Booker.run) and adding the necessary mocking for the booking cycle
Added proper mocking for auth_token and user_id to avoid authentication errors in the tests
Updated imports to use the src prefix for all imports
Added proper test structure for the booking window logic test
All tests now pass successfully
2025-08-12 01:38:50 +02:00
kbe
8d882ad091 refactor: Move files into src directory
Refactored project structure: Moved all Python modules to a src/ directory, updated imports accordingly. Added new environment variables to Dockerfile and docker-compose.yml. Removed Dockerfile.test and TODO file.
2025-08-12 01:10:26 +02:00
kbe
90230832ee refactor: Split code into many files 2025-08-12 00:53:16 +02:00
23 changed files with 1357 additions and 1050 deletions

View File

@@ -5,6 +5,8 @@ FROM python:3.11-slim
WORKDIR /app
# Set environment variables using the ARG values
ENV TARGET_RESERVATION_TIME=${TARGET_RESERVATION_TIME}
ENV BOOKING_WINDOW_END_DELTA_MINUTES=${BOOKING_WINDOW_END_DELTA_MINUTES}
ENV CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
ENV CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD}
ENV EMAIL_FROM=${EMAIL_FROM}

View File

@@ -1,17 +0,0 @@
# Use the official Python image from the Docker Hub
FROM python:3.11-slim
# Set the working directory
WORKDIR /app
# Copy the requirements file into the container
COPY requirements.txt .
# Install the dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code
COPY . .
# Run the test script
CMD ["python", "test_telegram_notifier.py"]

36
TODO
View File

@@ -1,36 +0,0 @@
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

@@ -1,539 +0,0 @@
# Native modules
import logging
import traceback
import os
import time
import difflib
from datetime import datetime, timedelta, date
# Third-party modules
import requests
from dateutil.parser import parse
import pytz
from dotenv import load_dotenv
from urllib.parse import urlencode
from typing import List, Dict, Optional, Any, Tuple
# Import the SessionNotifier class
from session_notifier import SessionNotifier
# Import the preferred sessions from the session_config module
from session_config import PREFERRED_SESSIONS
load_dotenv()
# Configuration
USERNAME = os.environ.get("CROSSFIT_USERNAME")
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
if not all([USERNAME, PASSWORD]):
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
APPLICATION_ID = "81560887"
CATEGORY_ID = "677" # Activity category ID for CrossFit
TIMEZONE = "Europe/Paris" # Adjust to your timezone
# Booking window configuration (can be overridden by environment variables)
# TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01)
# BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10)
TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10"))
DEVICE_TYPE = "3"
# Retry configuration
RETRY_MAX = 3
RETRY_BACKOFF = 1
APP_VERSION = "5.09.21"
class CrossFitBooker:
"""
A class for automating the booking of CrossFit sessions.
This class handles authentication, session availability checking,
booking, and notifications for CrossFit sessions.
"""
def __init__(self) -> None:
"""
Initialize the CrossFitBooker with necessary attributes.
Sets up authentication tokens, session headers, mandatory parameters,
and initializes the SessionNotifier for sending notifications.
"""
self.auth_token: Optional[str] = None
self.user_id: Optional[str] = None
self.session: requests.Session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"id_application": APPLICATION_ID,
"id_category_activity": CATEGORY_ID
}
# Initialize the SessionNotifier with credentials from environment variables
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
# Get notification settings from environment variables
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
self.notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization if available.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
headers: Dict[str, str] = self.base_headers.copy()
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
def login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
try:
# First login endpoint
login_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"username": USERNAME,
"password": PASSWORD
}
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(login_params))
if not response.ok:
logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
return False
try:
login_data: Dict[str, Any] = response.json()
self.user_id = str(login_data["data"]["user"]["id_user"])
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
# Second login endpoint
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/login",
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
data=urlencode({
"device_type": DEVICE_TYPE,
"username": USERNAME,
"password": PASSWORD
}))
if response.ok:
try:
login_data: Dict[str, Any] = response.json()
self.auth_token = login_data.get("token")
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
if self.auth_token and self.user_id:
logging.info("Successfully logged in")
return True
else:
logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {str(e)}")
return False
except Exception as e:
logging.error(f"Unexpected error during login: {str(e)}")
return False
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
if not self.auth_token or not self.user_id:
logging.error("Authentication required - missing token or user ID")
return None
url: str = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
# Prepare request with mandatory parameters
request_data: Dict[str, str] = self.mandatory_params.copy()
request_data.update({
"id_user": self.user_id,
"start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y")
})
# Add retry logic
for retry in range(RETRY_MAX):
try:
response: requests.Response = self.session.post(
url,
headers=self.get_auth_headers(),
data=urlencode(request_data),
timeout=10
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
return None
elif 500 <= response.status_code < 600:
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
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)
return None
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
data = {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": self.user_id,
"action_by": self.user_id,
"n_guests": "0",
"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)
logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
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):
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):
return json_response.get("data", [])
logging.error(f"API returned success:false: {json_response}")
return []
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return []
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)
logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
user_info: Dict[str, Any] = session.get("user_info", {})
# First check if can_join is true (primary condition)
if user_info.get("can_join", False):
return True
# Default case: not bookable
return False
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
try:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
day_of_week: int = session_time.weekday()
session_time_str: str = session_time.strftime("%H:%M")
session_name: str = session.get("name_activity", "").upper()
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
# Exact match
if (day_of_week == preferred_day and
session_time_str == preferred_time and
preferred_name in session_name):
return True
return False
except Exception as e:
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
return False
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
if not sessions:
logging.info("No sessions to display")
return
logging.info("Upcoming sessions:")
logging.info("ID\t\tName\t\tDate\t\tTime")
logging.info("="*50)
for session in sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
# Format session details
session_id = session.get("id_activity_calendar", "N/A")
session_name = session.get("name_activity", "N/A")
session_date = session_time.strftime("%Y-%m-%d")
session_time_str = session_time.strftime("%H:%M")
# Display session details
logging.info(f"{session_id}\t{session_name}\t{session_date}\t{session_time_str}")
async def booker(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
# Calculate date range to check (current day, day + 1, and day + 2)
start_date: date = current_time.date()
end_date: date = start_date + timedelta(days=2) # Only go up to day + 2
# Get available sessions
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):
logging.error("No sessions available or error fetching sessions")
return
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
# Display all available sessions within the date range
self.display_upcoming_sessions(activities, current_time)
# Find sessions to book (preferred only) within allowed date range
found_preferred_sessions: List[Dict[str, Any]] = []
for session in activities:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
# Check if session is within allowed date range (current day, day + 1, or day + 2)
days_diff = (session_time.date() - current_time.date()).days
if not (0 <= days_diff <= 2):
continue # Skip sessions outside the allowed date range
# Check if session is preferred and bookable
if self.is_session_bookable(session, current_time):
if self.matches_preferred_session(session, current_time):
found_preferred_sessions.append(session)
# Display preferred sessions found
if found_preferred_sessions:
logging.info("Preferred sessions found:")
for session in found_preferred_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No matching preferred sessions found")
# Book preferred sessions
if not found_preferred_sessions:
logging.info("No matching sessions found to book")
return
# Book sessions (preferred first)
sessions_to_book = [("Preferred", session) for session in found_preferred_sessions]
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
booked_sessions = []
for session_type, session in sessions_to_book:
session_time: datetime = parse(session["start_timestamp"])
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
if self.book_session(session["id_activity_calendar"]):
# Display booked session
booked_sessions.append(session)
logging.info(f"Successfully booked {session_type} session at {session_time}")
# Notify about booked session
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details)
else:
logging.error(f"Failed to book {session_type} session at {session_time}")
# Notify about failed booking
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
# Display all booked session(s)
if booked_sessions:
logging.info("Booked sessions:")
for session in booked_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No sessions were booked")
async def run(self) -> None:
"""
Main execution loop.
"""
# Set up timezone
tz: pytz.timezone = pytz.timezone(TIMEZONE)
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
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)
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
# Initial login
if not self.login():
logging.error("Authentication failed - exiting program")
return
try:
while True:
try:
current_time: datetime = datetime.now(tz)
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.booker(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:
"""
Clean up resources and exit the script.
"""
logging.info("Script interrupted by user. Quitting...")
exit(0)

View File

@@ -9,6 +9,8 @@ services:
- TZ=Europe/Paris
- CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
- CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD}
- TARGET_RESERVATION_TIME=${TARGET_RESERVATION_TIME}
- BOOKING_WINDOW_END_DELTA_MINUTES=${BOOKING_WINDOW_END_DELTA_MINUTES}
- SMTP_SERVER=${SMTP_SERVER}
- EMAIL_FROM=${EMAIL_FROM}
- EMAIL_TO=${EMAIL_TO}

31
main.py Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""
Main entry point for the CrossFit Booker application.
This script initializes the CrossFitBooker and starts the booking process.
"""
import asyncio
import logging
from src.crossfit_booker import CrossFitBooker
def main():
"""
Main function to initialize the CrossFitBooker and start the booking process.
"""
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler()
]
)
# Initialize the CrossFitBooker
booker = CrossFitBooker()
# Run the booking process
booker.run()
if __name__ == "__main__":
main()

14
setup.py Normal file
View File

@@ -0,0 +1,14 @@
from setuptools import setup, find_packages
setup(
name="crossfit_booker",
version="0.1",
packages=find_packages(where="src"),
package_dir={"": "src"},
install_requires=[
"requests",
"python-dotenv",
"pytz",
"python-dateutil",
],
)

18
src/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
# src/__init__.py
# Import all public modules to make them available as src.module
from .auth import AuthHandler
from .booker import Booker
from .crossfit_booker import CrossFitBooker
from .session_config import PREFERRED_SESSIONS
from .session_manager import SessionManager
from .session_notifier import SessionNotifier
__all__ = [
"AuthHandler",
"Booker",
"CrossFitBooker",
"PREFERRED_SESSIONS",
"SessionManager",
"SessionNotifier"
]

116
src/auth.py Normal file
View File

@@ -0,0 +1,116 @@
# Native modules
import logging
import os
from typing import Dict, Any, Optional
# Third-party modules
import requests
from urllib.parse import urlencode
# Configuration constants (will be moved from crossfit_booker.py)
APPLICATION_ID = "81560887"
DEVICE_TYPE = "3"
APP_VERSION = "5.09.21"
class AuthHandler:
"""
A class for handling authentication with the CrossFit booking system.
This class is responsible for performing login, retrieving auth tokens,
and providing authentication headers.
"""
def __init__(self, username: str, password: str) -> None:
"""
Initialize the AuthHandler with credentials.
Args:
username (str): The username for authentication.
password (str): The password for authentication.
"""
self.username = username
self.password = password
self.auth_token: Optional[str] = None
self.user_id: Optional[str] = None
self.session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization if available.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
headers: Dict[str, str] = self.base_headers.copy()
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
def login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
try:
# First login endpoint
login_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"username": self.username,
"password": self.password
}
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(login_params))
if not response.ok:
logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
return False
try:
login_data: Dict[str, Any] = response.json()
self.user_id = str(login_data["data"]["user"]["id_user"])
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
# Second login endpoint
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/login",
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
data=urlencode({
"device_type": DEVICE_TYPE,
"username": self.username,
"password": self.password
}))
if response.ok:
try:
login_data: Dict[str, Any] = response.json()
self.auth_token = login_data.get("token")
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
if self.auth_token and self.user_id:
logging.info("Successfully logged in")
return True
else:
logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {str(e)}")
return False
except Exception as e:
logging.error(f"Unexpected error during login: {str(e)}")
return False

View File

@@ -31,5 +31,5 @@ if __name__ == "__main__":
exit(1)
# Start the continuous booking loop
asyncio.run(booker.run())
booker.run()
logging.info("Script completed")

307
src/booker.py Normal file
View File

@@ -0,0 +1,307 @@
# Native modules
import logging
import traceback
import os
import time
from datetime import datetime, timedelta, date
# Third-party modules
import requests
from dateutil.parser import parse
import pytz
from dotenv import load_dotenv
from urllib.parse import urlencode
from typing import List, Dict, Optional, Any, Tuple
# Import the SessionNotifier class
from src.session_notifier import SessionNotifier
# Import the preferred sessions from the session_config module
from src.session_config import PREFERRED_SESSIONS
# Import the AuthHandler class
from src.auth import AuthHandler
# Import SessionManager
from src.session_manager import SessionManager
load_dotenv()
# Configuration
USERNAME = os.environ.get("CROSSFIT_USERNAME")
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
if not all([USERNAME, PASSWORD]):
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
APPLICATION_ID = "81560887"
CATEGORY_ID = "677" # Activity category ID for CrossFit
TIMEZONE = "Europe/Paris" # Adjust to your timezone
# Booking window configuration (can be overridden by environment variables)
# TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01)
# BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10)
TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10"))
DEVICE_TYPE = "3"
# Retry configuration
RETRY_MAX = 3
RETRY_BACKOFF = 1
APP_VERSION = "5.09.21"
class Booker:
"""
A class for handling the main booking logic.
This class is designed to be used as a standalone component
that can be initialized with authentication and session management
and used to perform the booking process.
"""
def __init__(self, auth_handler: AuthHandler, notifier: SessionNotifier) -> None:
"""
Initialize the Booker with necessary attributes.
Args:
auth_handler (AuthHandler): AuthHandler instance for authentication.
notifier (SessionNotifier): SessionNotifier instance for sending notifications.
"""
self.auth_handler = auth_handler
self.notifier = notifier
# Initialize the session and headers
self.session: requests.Session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"id_application": APPLICATION_ID,
"id_category_activity": CATEGORY_ID
}
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization from the AuthHandler.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
return self.auth_handler.get_auth_headers()
async def booker(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
# Calculate date range to check (current day, day + 1, and day + 2)
start_date: date = current_time.date()
end_date: date = start_date + timedelta(days=2) # Only go up to day + 2
# Get available sessions
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):
logging.error("No sessions available or error fetching sessions")
return
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
# Display all available sessions within the date range
self.display_upcoming_sessions(activities, current_time)
# Find sessions to book (preferred only) within allowed date range
found_preferred_sessions: List[Dict[str, Any]] = []
for session in activities:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
# Check if session is within allowed date range (current day, day + 1, or day + 2)
days_diff = (session_time.date() - current_time.date()).days
if not (0 <= days_diff <= 2):
continue # Skip sessions outside the allowed date range
# Check if session is preferred and bookable
if self.is_session_bookable(session, current_time):
if self.matches_preferred_session(session, current_time):
found_preferred_sessions.append(session)
# Display preferred sessions found
if found_preferred_sessions:
logging.info("Preferred sessions found:")
for session in found_preferred_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No matching preferred sessions found")
# Book preferred sessions
if not found_preferred_sessions:
logging.info("No matching sessions found to book")
return
# Book sessions (preferred first)
sessions_to_book = [("Preferred", session) for session in found_preferred_sessions]
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
booked_sessions = []
for session_type, session in sessions_to_book:
session_time: datetime = parse(session["start_timestamp"])
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
if self.book_session(session["id_activity_calendar"]):
# Display booked session
booked_sessions.append(session)
logging.info(f"Successfully booked {session_type} session at {session_time}")
# Notify about booked session
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details)
else:
logging.error(f"Failed to book {session_type} session at {session_time}")
# Notify about failed booking
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
# Display all booked session(s)
if booked_sessions:
logging.info("Booked sessions:")
for session in booked_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No sessions were booked")
async def run(self) -> None:
"""
Main execution loop.
"""
# Set up timezone
tz: pytz.timezone = pytz.timezone(TIMEZONE)
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
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)
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
# Initial login
if not self.auth_handler.login():
logging.error("Authentication failed - exiting program")
return
try:
while True:
try:
current_time: datetime = datetime.now(tz)
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.booker(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:
"""
Clean up resources and exit the script.
"""
logging.info("Script interrupted by user. Quitting...")
exit(0)
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.get_available_sessions(start_date, end_date)
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.book_session(session_id)
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
A list of dictionaries containing information about the booked sessions.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.get_booked_sessions()
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.is_session_bookable(session, current_time)
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.matches_preferred_session(session, current_time)
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
session_manager = SessionManager(self.auth_handler)
session_manager.display_upcoming_sessions(sessions, current_time)

191
src/crossfit_booker.py Normal file
View File

@@ -0,0 +1,191 @@
# Native modules
import logging
import os
from typing import Dict, Any, Optional, List
from datetime import date, datetime
# Third-party modules
import requests
# Import the AuthHandler class
from src.auth import AuthHandler
# Import the SessionManager class
from src.session_manager import SessionManager
# Import the Booker class
from src.booker import Booker
# Import the SessionNotifier class
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# Configuration
USERNAME = os.environ.get("CROSSFIT_USERNAME")
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
if not all([USERNAME, PASSWORD]):
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
class CrossFitBooker:
"""
A simple orchestrator class for the CrossFit booking system.
This class is responsible for initializing and coordinating the other components
(AuthHandler, SessionManager, and Booker) and provides a unified interface for
interacting with the booking system.
"""
def __init__(self) -> None:
"""
Initialize the CrossFitBooker with necessary components.
"""
# Initialize the AuthHandler with credentials from environment variables
self.auth_handler = AuthHandler(USERNAME, PASSWORD)
# Initialize the SessionManager with the AuthHandler
self.session_manager = SessionManager(self.auth_handler)
# Initialize the SessionNotifier with credentials from environment variables
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
# Get notification settings from environment variables
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
self.notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
# Initialize the Booker with the AuthHandler and SessionNotifier
self.booker = Booker(self.auth_handler, self.notifier)
# Initialize a session for direct API calls
self.session = requests.Session()
def run(self) -> None:
"""
Start the booking process.
This method initiates the booking process by running the Booker's main execution loop.
"""
import asyncio
asyncio.run(self.booker.run())
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization from the AuthHandler.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
return self.auth_handler.get_auth_headers()
def login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
return self.auth_handler.login()
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
return self.session_manager.get_available_sessions(start_date, end_date)
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
return self.session_manager.book_session(session_id)
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
A list of dictionaries containing information about the booked sessions.
"""
return self.session_manager.get_booked_sessions()
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
return self.session_manager.is_session_bookable(session, current_time)
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
return self.session_manager.matches_preferred_session(session, current_time)
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
self.session_manager.display_upcoming_sessions(sessions, current_time)
async def run_booking_cycle(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
await self.booker.booker(current_time)
def quit(self) -> None:
"""
Clean up resources and exit the script.
"""
self.booker.quit()

314
src/session_manager.py Normal file
View File

@@ -0,0 +1,314 @@
# Native modules
import logging
import pytz
import time
from datetime import date
from typing import List, Dict, Optional, Any
from datetime import datetime, timedelta, date
# Third-party modules
import requests
from dateutil.parser import parse
# Import the preferred sessions from the session_config module
from src.session_config import PREFERRED_SESSIONS
# Import the AuthHandler class
from src.auth import AuthHandler
class SessionManager:
"""
A class for managing CrossFit sessions.
This class handles session availability checking, booking,
and session-related operations.
"""
def __init__(self, auth_handler: AuthHandler) -> None:
"""
Initialize the SessionManager with necessary attributes.
Args:
auth_handler (AuthHandler): AuthHandler instance for authentication.
"""
self.auth_handler = auth_handler
self.session = requests.Session()
self.base_headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params = {
"app_version": "5.09.21",
"device_type": "3",
"id_application": "81560887",
"id_category_activity": "677"
}
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization from the AuthHandler.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
return self.auth_handler.get_auth_headers()
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
if not self.auth_handler.auth_token or not self.auth_handler.user_id:
logging.error("Authentication required - missing token or user ID")
return None
url = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
# Prepare request with mandatory parameters
request_data = self.mandatory_params.copy()
request_data.update({
"id_user": self.auth_handler.user_id,
"start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y")
})
# Add retry logic
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=request_data,
timeout=10
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
return None
elif 500 <= response.status_code < 600:
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
except requests.exceptions.RequestException as e:
if retry == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
return None
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
data = {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": self.auth_handler.user_id,
"action_by": self.auth_handler.user_id,
"n_guests": "0",
"booked_on": "1",
"device_type": self.mandatory_params["device_type"],
"token": self.auth_handler.auth_token
}
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=data,
timeout=10
)
if response.status_code == 200:
json_response = 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 == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error(f"Failed to complete request after 3 attempts")
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
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.auth_handler.user_id,
"action_by": self.auth_handler.user_id
}
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=data,
timeout=10
)
if response.status_code == 200:
json_response = response.json()
if json_response.get("success", False):
return json_response.get("data", [])
logging.error(f"API returned success:false: {json_response}")
return []
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return []
except requests.exceptions.RequestException as e:
if retry == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error(f"Failed to complete request after 3 attempts")
return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
user_info = session.get("user_info", {})
# First check if can_join is true (primary condition)
if user_info.get("can_join", False):
return True
# Check if booking window is in the past
unable_to_book_until_date = user_info.get("unableToBookUntilDate", "")
unable_to_book_until_time = user_info.get("unableToBookUntilTime", "")
if unable_to_book_until_date and unable_to_book_until_time:
try:
# Parse the date and time
booking_window_time_str = f"{unable_to_book_until_date} {unable_to_book_until_time}"
booking_window_time = parse(booking_window_time_str)
if not booking_window_time.tzinfo:
booking_window_time = pytz.timezone("Europe/Paris").localize(booking_window_time)
# If current time is after the booking window, session is bookable
if current_time > booking_window_time:
return True
except (ValueError, TypeError):
# If parsing fails, default to not bookable
pass
# Default case: not bookable
return False
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
try:
session_time = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone("Europe/Paris").localize(session_time)
day_of_week = session_time.weekday()
session_time_str = session_time.strftime("%H:%M")
session_name = session.get("name_activity", "").upper()
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
# Exact match
if (day_of_week == preferred_day and
session_time_str == preferred_time and
preferred_name in session_name):
return True
return False
except Exception as e:
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
return False
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
if not sessions:
logging.info("No sessions to display")
return
logging.info("Upcoming sessions:")
logging.info("ID\t\tName\t\tDate\t\tTime")
logging.info("="*50)
for session in sessions:
session_time = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone("Europe/Paris").localize(session_time)
# Format session details
session_id = session.get("id_activity_calendar", "N/A")
session_name = session.get("name_activity", "N/A")
session_date = session_time.strftime("%Y-%m-%d")
session_time_str = session_time.strftime("%H:%M")
# Display session details
logging.info(f"{session_id}\t{session_name}\t{session_date}\t{session_time_str}")

135
test/test_auth.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Unit tests for AuthHandler class
"""
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 src.auth import AuthHandler
class TestAuthHandlerAuthHeaders:
"""Test cases for get_auth_headers method"""
def test_get_auth_headers_without_token(self):
"""Test headers without auth token"""
auth_handler = AuthHandler('test_user', 'test_pass')
headers = auth_handler.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"""
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token_123"
headers = auth_handler.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestAuthHandlerLogin:
"""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]
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is True
assert auth_handler.user_id == "12345"
assert auth_handler.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
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
assert auth_handler.user_id is None
assert auth_handler.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]
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.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
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.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")
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,243 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the refactored CrossFitBooker functional implementation.
"""
import sys
import os
from datetime import datetime, date, timedelta
import pytz
from typing import List, Tuple
# 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.abspath(os.path.join(os.path.dirname(__file__), '..')))
# Import the functional functions from our refactored code
from crossfit_booker_functional import (
is_session_bookable,
matches_preferred_session,
filter_bookable_sessions,
filter_preferred_sessions,
categorize_sessions,
format_session_details
)
from crossfit_booker import CrossFitBooker
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 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():
"""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()
test_book_session()
print("\n✓ All tests passed!")
if __name__ == "__main__":
run_all_tests()

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker authentication methods
Unit tests for CrossFitBooker authentication methods using AuthHandler
"""
import pytest
@@ -12,8 +12,8 @@ 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
from src.crossfit_booker import CrossFitBooker
from src.auth import AuthHandler
class TestCrossFitBookerAuthHeaders:
"""Test cases for get_auth_headers method"""
@@ -36,11 +36,10 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token_123"
booker.auth_handler.auth_token = "test_token_123"
headers = booker.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestCrossFitBookerLogin:
"""Test cases for login method"""
@@ -75,8 +74,8 @@ class TestCrossFitBookerLogin:
result = booker.login()
assert result is True
assert booker.user_id == "12345"
assert booker.auth_token == "test_bearer_token"
assert booker.auth_handler.user_id == "12345"
assert booker.auth_handler.auth_token == "test_bearer_token"
@patch('requests.Session.post')
def test_login_first_step_failure(self, mock_post):
@@ -96,8 +95,8 @@ class TestCrossFitBookerLogin:
result = booker.login()
assert result is False
assert booker.user_id is None
assert booker.auth_token is None
assert booker.auth_handler.user_id is None
assert booker.auth_handler.auth_token is None
@patch('requests.Session.post')
def test_login_second_step_failure(self, mock_post):
@@ -161,6 +160,5 @@ class TestCrossFitBookerLogin:
assert result is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -12,8 +12,7 @@ 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
from src.crossfit_booker import CrossFitBooker
class TestCrossFitBookerInit:
"""Test cases for CrossFitBooker initialization"""
@@ -30,8 +29,8 @@ class TestCrossFitBookerInit:
'TELEGRAM_CHAT_ID': '12345'
}):
booker = CrossFitBooker()
assert booker.auth_token is None
assert booker.user_id is None
assert booker.auth_handler.auth_token is None
assert booker.auth_handler.user_id is None
assert booker.session is not None
assert booker.notifier is not None
@@ -54,7 +53,6 @@ class TestCrossFitBookerInit:
except ValueError as e:
assert str(e) == "Missing environment variables"
class TestCrossFitBookerAuthHeaders:
"""Test cases for get_auth_headers method"""
@@ -76,11 +74,10 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token_123"
booker.auth_handler.auth_token = "test_token_123"
headers = booker.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestCrossFitBookerLogin:
"""Test cases for login method"""
@@ -115,8 +112,8 @@ class TestCrossFitBookerLogin:
result = booker.login()
assert result is True
assert booker.user_id == "12345"
assert booker.auth_token == "test_bearer_token"
assert booker.auth_handler.user_id == "12345"
assert booker.auth_handler.auth_token == "test_bearer_token"
@patch('requests.Session.post')
def test_login_first_step_failure(self, mock_post):
@@ -136,8 +133,8 @@ class TestCrossFitBookerLogin:
result = booker.login()
assert result is False
assert booker.user_id is None
assert booker.auth_token is None
assert booker.auth_handler.user_id is None
assert booker.auth_handler.auth_token is None
@patch('requests.Session.post')
def test_login_json_parsing_error(self, mock_post):
@@ -171,7 +168,6 @@ class TestCrossFitBookerLogin:
assert result is False
class TestCrossFitBookerGetAvailableSessions:
"""Test cases for get_available_sessions method"""
@@ -206,8 +202,8 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
@@ -228,8 +224,8 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))

View File

@@ -6,14 +6,17 @@ Unit tests for CrossFitBooker session-related methods
import pytest
import os
import sys
from unittest.mock import patch, Mock
from datetime import datetime, date
from unittest.mock import patch, Mock, AsyncMock
from datetime import datetime, timedelta, 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
from src.crossfit_booker import CrossFitBooker
from src.session_manager import SessionManager
from src.auth import AuthHandler
class TestCrossFitBookerGetAvailableSessions:
@@ -25,8 +28,9 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is None
@patch('requests.Session.post')
@@ -49,11 +53,12 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is not None
assert result["success"] is True
@@ -71,14 +76,13 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.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"""
@@ -88,8 +92,9 @@ class TestCrossFitBookerBookSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.book_session("session_123")
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
result = session_manager.book_session("session_123")
assert result is False
@patch('requests.Session.post')
@@ -105,11 +110,12 @@ class TestCrossFitBookerBookSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = booker.book_session("session_123")
result = session_manager.book_session("session_123")
assert result is True
@@ -126,15 +132,15 @@ class TestCrossFitBookerBookSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_token = "test_token"
booker.user_id = "12345"
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = booker.book_session("session_123")
result = session_manager.book_session("session_123")
assert result is False
class TestCrossFitBookerIsSessionBookable:
"""Test cases for is_session_bookable method"""
@@ -144,11 +150,12 @@ class TestCrossFitBookerIsSessionBookable:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {"user_info": {"can_join": True}}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = booker.is_session_bookable(session, current_time)
result = session_manager.is_session_bookable(session, current_time)
assert result is True
def test_is_session_bookable_booking_window_past(self):
@@ -157,7 +164,8 @@ class TestCrossFitBookerIsSessionBookable:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"user_info": {
"can_join": False,
@@ -167,7 +175,7 @@ class TestCrossFitBookerIsSessionBookable:
}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = booker.is_session_bookable(session, current_time)
result = session_manager.is_session_bookable(session, current_time)
assert result is True
def test_is_session_bookable_booking_window_future(self):
@@ -176,7 +184,8 @@ class TestCrossFitBookerIsSessionBookable:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"user_info": {
"can_join": False,
@@ -186,29 +195,33 @@ class TestCrossFitBookerIsSessionBookable:
}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = booker.is_session_bookable(session, current_time)
result = session_manager.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')
@patch('src.crossfit_booker.CrossFitBooker.get_available_sessions')
@patch('src.crossfit_booker.CrossFitBooker.is_session_bookable')
@patch('src.crossfit_booker.CrossFitBooker.matches_preferred_session')
@patch('src.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()
# Mock the auth_token and user_id to avoid authentication errors
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
# Mock the booker method to use our mocked methods
with patch.object(booker.booker, 'get_available_sessions', mock_get_sessions):
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')
@patch('src.crossfit_booker.CrossFitBooker.get_available_sessions')
@patch('src.crossfit_booker.CrossFitBooker.is_session_bookable')
@patch('src.crossfit_booker.CrossFitBooker.matches_preferred_session')
@patch('src.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
@@ -233,6 +246,14 @@ class TestCrossFitBookerRunBookingCycle:
mock_book_session.return_value = True
booker = CrossFitBooker()
# Mock the auth_token and user_id to avoid authentication errors
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
# Mock the booker method to use our mocked methods
with patch.object(booker.booker, 'get_available_sessions', mock_get_sessions):
with patch.object(booker.booker, 'is_session_bookable', mock_is_bookable):
with patch.object(booker.booker, 'matches_preferred_session', mock_matches_preferred):
with patch.object(booker.booker, 'book_session', mock_book_session):
await booker.run_booking_cycle(current_time)
mock_get_sessions.assert_called_once()
@@ -244,27 +265,20 @@ class TestCrossFitBookerRunBookingCycle:
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):
def test_run_auth_failure(self):
"""Test run with authentication failure"""
mock_login.return_value = False
with patch('src.crossfit_booker.CrossFitBooker.login', return_value=False) as mock_login:
booker = CrossFitBooker()
with patch.object(booker, 'run', new=booker.run) as mock_run:
await booker.run()
# Test the authentication failure path through the booker
result = booker.login()
assert result is False
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):
def test_run_booking_outside_window(self):
"""Test run with booking outside window"""
mock_login.return_value = True
mock_quit.return_value = None # Prevent actual exit
with patch('src.booker.Booker.run') as mock_run:
with patch('datetime.datetime') as mock_datetime:
with patch('time.sleep') as mock_sleep:
# Create a time outside the booking window (19:00)
tz = pytz.timezone("Europe/Paris")
mock_now = datetime(2025, 7, 25, 19, 0, tzinfo=tz)
@@ -284,19 +298,19 @@ class TestCrossFitBookerRun:
booker = CrossFitBooker()
try:
await booker.run()
except KeyboardInterrupt:
pass # Expected to break the loop
# Test the booking window logic directly
target_hour, target_minute = map(int, "20:01".split(":"))
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
booking_window_end = target_time + timedelta(minutes=10)
# Verify login was called
mock_login.assert_called_once()
# Current time is outside the booking window
assert not (target_time <= mock_now <= booking_window_end)
# Verify run_booking_cycle was NOT called since we're outside the booking window
mock_run_booking_cycle.assert_not_called()
# Run the booker to trigger the login
booker.run()
# Verify quit was called (due to KeyboardInterrupt)
mock_quit.assert_called_once()
# Verify run was called
mock_run.assert_called_once()
class TestCrossFitBookerQuit:
"""Test cases for quit method"""
@@ -308,6 +322,7 @@ class TestCrossFitBookerQuit:
with pytest.raises(SystemExit) as excinfo:
booker.quit()
assert excinfo.value.code == 0
class TestCrossFitBookerMatchesPreferredSession:
"""Test cases for matches_preferred_session method"""
@@ -317,7 +332,8 @@ class TestCrossFitBookerMatchesPreferredSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "CONDITIONING"
@@ -325,8 +341,8 @@ class TestCrossFitBookerMatchesPreferredSession:
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)
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is True
def test_matches_preferred_session_fuzzy_match(self):
@@ -335,7 +351,8 @@ class TestCrossFitBookerMatchesPreferredSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "CONDITIONING WORKOUT"
@@ -343,8 +360,8 @@ class TestCrossFitBookerMatchesPreferredSession:
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)
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is True
def test_matches_preferred_session_no_match(self):
@@ -353,7 +370,8 @@ class TestCrossFitBookerMatchesPreferredSession:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "YOGA"
@@ -361,6 +379,6 @@ class TestCrossFitBookerMatchesPreferredSession:
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)
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is False

View File

@@ -12,7 +12,7 @@ from unittest.mock import patch, mock_open
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_config import SessionConfig
from src.session_config import SessionConfig
class TestSessionConfig:

View File

@@ -12,7 +12,7 @@ from unittest.mock import patch, MagicMock
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_notifier import SessionNotifier
from src.session_notifier import SessionNotifier
@pytest.fixture
def email_credentials():

View File

@@ -10,7 +10,7 @@ from dotenv import load_dotenv
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_notifier import SessionNotifier
from src.session_notifier import SessionNotifier
# Load environment variables from .env file
load_dotenv()