feat: implement Fitbit OAuth, Garmin MFA, and optimize segment discovery
- Add Fitbit authentication flow (save credentials, OAuth callback handling) - Implement Garmin MFA support with successful session/cookie handling - Optimize segment discovery with new sampling and activity query services - Refactor database session management in discovery API for better testability - Enhance activity data parsing for charts and analysis - Update tests to use testcontainers and proper dependency injection - Clean up repository by ignoring and removing tracked transient files (.pyc, .db)
This commit is contained in:
Binary file not shown.
153
FitnessSync/backend/tests/integration/test_activities_api.py
Normal file
153
FitnessSync/backend/tests/integration/test_activities_api.py
Normal file
@@ -0,0 +1,153 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from datetime import datetime, timedelta
|
||||
from src.api.activities import router
|
||||
from src.models.activity import Activity
|
||||
from src.models.bike_setup import BikeSetup
|
||||
from src.models.activity_state import GarminActivityState
|
||||
from src.api.activities import get_db
|
||||
from main import app # Use main app instance
|
||||
|
||||
# Mock Objects matches Pydantic models structure where needed,
|
||||
# but for SQLAlchemy response matching, standard objects work.
|
||||
|
||||
def mock_activity(garmin_id="12345", name="Test Ride"):
|
||||
# Mocking a SQLAlchemy Activity object
|
||||
act = MagicMock(spec=Activity)
|
||||
act.id = int(garmin_id)
|
||||
act.garmin_activity_id = garmin_id
|
||||
act.activity_name = name
|
||||
act.activity_type = "cycling"
|
||||
act.start_time = datetime(2023, 1, 1, 10, 0, 0)
|
||||
act.duration = 3600.0
|
||||
act.file_type = "fit"
|
||||
act.download_status = "downloaded"
|
||||
act.downloaded_at = datetime(2023, 1, 1, 12, 0, 0)
|
||||
act.avg_power = 200
|
||||
act.avg_hr = 150
|
||||
act.avg_cadence = 90
|
||||
act.is_estimated_power = False
|
||||
|
||||
# Relationships
|
||||
bike = MagicMock(spec=BikeSetup)
|
||||
bike.id = 1
|
||||
bike.name = "Road Bike"
|
||||
bike.frame = "Carbon"
|
||||
bike.chainring = 50
|
||||
bike.rear_cog = 11
|
||||
act.bike_setup = bike
|
||||
act.bike_setup_id = 1
|
||||
|
||||
# File content for details overrides
|
||||
act.file_content = b"mock_content"
|
||||
|
||||
# Extended stats for details
|
||||
act.distance = 20000.0
|
||||
act.calories = 800.0
|
||||
act.max_hr = 180
|
||||
act.avg_speed = 8.5
|
||||
act.max_speed = 12.0
|
||||
act.elevation_gain = 500.0
|
||||
act.elevation_loss = 500.0
|
||||
act.max_cadence = 100
|
||||
act.steps = 0
|
||||
act.vo2_max = 50.0
|
||||
|
||||
return act
|
||||
|
||||
def mock_activity_state(garmin_id="12345", name="Test Ride"):
|
||||
state = MagicMock(spec=GarminActivityState)
|
||||
state.garmin_activity_id = garmin_id
|
||||
state.activity_name = name
|
||||
state.activity_type = "cycling"
|
||||
state.start_time = datetime(2023, 1, 1, 10, 0, 0)
|
||||
state.sync_status = "synced"
|
||||
return state
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_db_session):
|
||||
def override_get_db():
|
||||
try:
|
||||
yield mock_db_session
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_list_activities(client, mock_db_session):
|
||||
# Setup Mock Return
|
||||
# list_activities queries (GarminActivityState, Activity)
|
||||
|
||||
state1 = mock_activity_state("1001", "Morning Ride")
|
||||
act1 = mock_activity("1001", "Morning Ride")
|
||||
|
||||
state2 = mock_activity_state("1002", "Evening Ride")
|
||||
act2 = mock_activity("1002", "Evening Ride")
|
||||
|
||||
# Mock query().outerjoin().order_by().offset().limit().all() chain
|
||||
# It's a bit long chain to mock perfectly.
|
||||
# db.query(...) returns Query object.
|
||||
|
||||
mock_query = mock_db_session.query.return_value
|
||||
mock_query.outerjoin.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = [(state1, act1), (state2, act2)]
|
||||
|
||||
response = client.get("/api/activities/list?limit=10&offset=0")
|
||||
|
||||
# Check execution
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["garmin_activity_id"] == "1001"
|
||||
assert data[0]["activity_name"] == "Morning Ride"
|
||||
assert data[0]["download_status"] == "downloaded"
|
||||
assert data[0]["bike_setup"]["name"] == "Road Bike"
|
||||
|
||||
def test_get_activity_details(client, mock_db_session):
|
||||
# Setup
|
||||
act = mock_activity("2001", "Detail Test")
|
||||
|
||||
# db.query(Activity).filter(...).first()
|
||||
mock_query = mock_db_session.query.return_value
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.first.return_value = act
|
||||
|
||||
response = client.get(f"/api/activities/2001/details")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["garmin_activity_id"] == "2001"
|
||||
assert data["distance"] == 20000.0
|
||||
assert data["bike_setup"]["name"] == "Road Bike"
|
||||
|
||||
def test_get_activity_streams_mock(client, mock_db_session):
|
||||
act = mock_activity("3001", "Stream Test")
|
||||
act.streams_json = None
|
||||
act.file_content = None
|
||||
|
||||
# Mock query returning activity directly
|
||||
mock_db_session.query.return_value.filter.return_value.first.return_value = act
|
||||
|
||||
response = client.get(f"/api/activities/3001/streams")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Expect empty streams structure
|
||||
expected_keys = ["time", "heart_rate", "power", "altitude", "speed", "cadence", "respiration_rate"]
|
||||
for k in expected_keys:
|
||||
assert k in data
|
||||
assert data[k] == []
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.services.bike_matching import run_matching_for_all
|
||||
from src.models.activity import Activity
|
||||
from src.models.bike_setup import BikeSetup
|
||||
|
||||
def test_bike_matching_job_updates_progress():
|
||||
"""
|
||||
Verify that run_matching_for_all calls job_manager.update_job
|
||||
when a job_id is provided.
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
|
||||
# Mock Activities
|
||||
activities = [
|
||||
Activity(id=1, activity_type="cycling", bike_setup_id=None, bike_match_confidence=None),
|
||||
Activity(id=2, activity_type="cycling", bike_setup_id=None, bike_match_confidence=None)
|
||||
]
|
||||
|
||||
# Setup Query chain
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = activities
|
||||
|
||||
with patch("src.services.bike_matching.process_activity_matching") as mock_process:
|
||||
with patch("src.services.job_manager.job_manager") as mock_job_manager:
|
||||
# Important: Ensure expecting cancellation returns False, otherwise loop breaks
|
||||
mock_job_manager.should_cancel.return_value = False
|
||||
|
||||
run_matching_for_all(mock_db, job_id="test-job-123")
|
||||
|
||||
# Verify update_job called at start
|
||||
mock_job_manager.update_job.assert_any_call("test-job-123", message="Found 2 candidates. Matching...", progress=0)
|
||||
|
||||
# Verify update_job called during loop (progress)
|
||||
# 2 items, index 0 satisfies % 10 == 0
|
||||
mock_job_manager.update_job.assert_any_call("test-job-123", progress=0)
|
||||
|
||||
# Verify process was called
|
||||
assert mock_process.call_count == 2
|
||||
55
FitnessSync/backend/tests/integration/test_discovery_fix.py
Normal file
55
FitnessSync/backend/tests/integration/test_discovery_fix.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from src.models.activity import Activity
|
||||
from src.services.parsers import extract_activity_data
|
||||
import os
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_returns_file_type_when_db_type_missing(db_session, client, tmp_path):
|
||||
"""
|
||||
Verify that the discovery API returns the activity type parsed from the file
|
||||
even if the database record has activity_type=None.
|
||||
"""
|
||||
# 1. Create dummy FIT file content (minimal valid header/data or mock)
|
||||
# Using a real file is better, or mocking extract_activity_data.
|
||||
# Let's mock extract_activity_data to avoid needing a complex binary file.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
# Mock return value mimics a running activity
|
||||
mock_parsed_data = {
|
||||
'type': 'running',
|
||||
'points': [[-122.4, 37.8], [-122.41, 37.81]], # Dummy points
|
||||
'timestamps': []
|
||||
}
|
||||
|
||||
with patch('src.api.discovery.extract_activity_data', return_value=mock_parsed_data) as mock_extract:
|
||||
# 2. Create Activity in DB with type=None
|
||||
activity = Activity(
|
||||
activity_name="Test Activity",
|
||||
garmin_activity_id="99999",
|
||||
start_time="2023-01-01T10:00:00",
|
||||
activity_type=None, # <--- MISSING TYPE
|
||||
file_type="fit",
|
||||
file_content=b"dummy_bytes"
|
||||
)
|
||||
db_session.add(activity)
|
||||
db_session.commit()
|
||||
db_session.refresh(activity)
|
||||
|
||||
# 3. Call Discovery Single API
|
||||
payload = {
|
||||
"activity_id": activity.id,
|
||||
"pause_threshold": 10,
|
||||
"rdp_epsilon": 10,
|
||||
"turn_threshold": 60,
|
||||
"min_length": 100
|
||||
}
|
||||
|
||||
response = client.post("/api/discovery/single", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# 4. Assert analyzed_activity_type is 'running'
|
||||
assert data['analyzed_activity_type'] == 'running'
|
||||
print("Success: API returned inferred type 'running'")
|
||||
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from src.models.activity import Activity
|
||||
import os
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_by_garmin_id(db_session, client, tmp_path):
|
||||
"""
|
||||
Verify that the discovery API correctly finds an activity when passed its
|
||||
Garmin Activity ID (as an int/string) instead of the internal DB ID.
|
||||
"""
|
||||
# 1. Create Activity with a specific Garmin ID
|
||||
garmin_id = "9876543210" # Large ID
|
||||
activity = Activity(
|
||||
activity_name="Garmin Test Activity",
|
||||
garmin_activity_id=garmin_id,
|
||||
start_time="2023-05-20T10:00:00",
|
||||
activity_type="hiking",
|
||||
file_type="fit",
|
||||
file_content=b"dummy"
|
||||
)
|
||||
db_session.add(activity)
|
||||
db_session.commit()
|
||||
db_session.refresh(activity)
|
||||
|
||||
# Internal ID should be small (e.g. 1)
|
||||
print(f"DEBUG: Created Activity Internal ID: {activity.id}, Garmin ID: {activity.garmin_activity_id}")
|
||||
|
||||
# 2. Call Discovery Single API with the GARMIN ID
|
||||
payload = {
|
||||
"activity_id": int(garmin_id), # Passing the large ID
|
||||
"pause_threshold": 10,
|
||||
"rdp_epsilon": 10,
|
||||
"turn_threshold": 60,
|
||||
"min_length": 100
|
||||
}
|
||||
|
||||
response = client.post("/api/discovery/single", json=payload)
|
||||
|
||||
# Needs to be 200 OK
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# 3. Assert analyzed_activity_type is 'hiking' (retrieved from DB via Garmin ID lookup)
|
||||
assert data['analyzed_activity_type'] == 'hiking'
|
||||
print("Success: API found activity via Garmin ID and returned type 'hiking'")
|
||||
@@ -27,7 +27,8 @@ def test_setup_garmin_success(mock_garth_client, mock_garth_login, client: TestC
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "message": "Logged in and tokens saved."}
|
||||
mock_garth_login.assert_called_once_with("testuser", "testpassword")
|
||||
assert response.json() == {"status": "success", "message": "Logged in and tokens saved."}
|
||||
mock_garth_login.assert_called_once_with("testuser", "testpassword", return_on_mfa=True)
|
||||
|
||||
# Verify token saved in DB
|
||||
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
|
||||
@@ -41,13 +42,24 @@ def test_setup_garmin_success(mock_garth_client, mock_garth_login, client: TestC
|
||||
@patch("garth.client")
|
||||
def test_setup_garmin_mfa_required(mock_garth_client, mock_garth_login, client: TestClient, db_session: Session):
|
||||
"""Test Garmin login via API when MFA is required."""
|
||||
mock_garth_login.side_effect = GarthException("needs-mfa")
|
||||
|
||||
# Mock garth.client.mfa_state as it would be set by garth.login
|
||||
# (Actually if return value is tuple, implementation uses tuple[1] as mfa_state)
|
||||
|
||||
mock_client_for_mfa = MagicMock()
|
||||
mock_client_for_mfa._session = MagicMock()
|
||||
mock_client_for_mfa._session.cookies.get_dict.return_value = {"cookie1": "val1"}
|
||||
mock_client_for_mfa.sess = MagicMock()
|
||||
mock_client_for_mfa.sess.cookies.get_dict.return_value = {"cookie1": "val1"}
|
||||
mock_client_for_mfa.domain = "garmin.com"
|
||||
mock_client_for_mfa.last_resp.text = "response_text"
|
||||
mock_client_for_mfa.last_resp.url = "http://garmin.com/response"
|
||||
|
||||
# Mock return tuple (status, state) instead of raising exception
|
||||
# Must include client object in state so initiate_mfa uses our configured mock
|
||||
mock_garth_login.return_value = ("needs_mfa", {
|
||||
"signin_params": {"param1": "value1"},
|
||||
"mfa": "state",
|
||||
"client": mock_client_for_mfa
|
||||
})
|
||||
mock_garth_login.side_effect = None
|
||||
|
||||
mock_garth_client.mfa_state = {
|
||||
"signin_params": {"param1": "value1"},
|
||||
@@ -60,8 +72,10 @@ def test_setup_garmin_mfa_required(mock_garth_client, mock_garth_login, client:
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {"status": "mfa_required", "message": "MFA code required."}
|
||||
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword")
|
||||
response_json = response.json()
|
||||
assert response_json["status"] == "mfa_required"
|
||||
assert response_json["message"] == "MFA code required."
|
||||
mock_garth_login.assert_called_once_with("testmfauser", "testmfapassword", return_on_mfa=True)
|
||||
|
||||
# Verify MFA state saved in DB
|
||||
token_record = db_session.query(APIToken).filter_by(token_type='garmin').first()
|
||||
@@ -84,7 +98,7 @@ def test_setup_garmin_login_failure(mock_garth_login, client: TestClient, db_ses
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Login failed. Check username/password." # Updated message
|
||||
mock_garth_login.assert_called_once_with("wronguser", "wrongpassword")
|
||||
mock_garth_login.assert_called_once_with("wronguser", "wrongpassword", return_on_mfa=True)
|
||||
assert db_session.query(APIToken).count() == 0 # No token saved on failure
|
||||
|
||||
|
||||
@@ -110,8 +124,8 @@ def test_complete_garmin_mfa_success(mock_garth_client, mock_garth_client_class,
|
||||
|
||||
# Mock Client constructor (called by actual code)
|
||||
mock_client_instance = MagicMock(spec=Client)
|
||||
mock_client_instance._session = MagicMock()
|
||||
mock_client_instance._session.cookies.update.return_value = None # No return needed
|
||||
mock_client_instance.sess = MagicMock()
|
||||
mock_client_instance.sess.cookies.update.return_value = None # No return needed
|
||||
mock_garth_client_class.return_value = mock_client_instance
|
||||
|
||||
# Mock garth.resume_login to succeed
|
||||
@@ -130,7 +144,7 @@ def test_complete_garmin_mfa_success(mock_garth_client, mock_garth_client_class,
|
||||
assert response.json() == {"status": "success", "message": "MFA verification successful, tokens saved."}
|
||||
|
||||
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
|
||||
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
|
||||
mock_client_instance.sess.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
|
||||
mock_garth_resume_login.assert_called_once()
|
||||
|
||||
# Verify DB updated
|
||||
@@ -159,8 +173,8 @@ def test_complete_garmin_mfa_failure(mock_garth_client_class, mock_garth_resume_
|
||||
|
||||
# Mock Client constructor
|
||||
mock_client_instance = MagicMock(spec=Client)
|
||||
mock_client_instance._session = MagicMock()
|
||||
mock_client_instance._session.cookies.update.return_value = None
|
||||
mock_client_instance.sess = MagicMock()
|
||||
mock_client_instance.sess.cookies.update.return_value = None
|
||||
mock_garth_client_class.return_value = mock_client_instance
|
||||
|
||||
# Mock garth.resume_login to fail
|
||||
@@ -174,7 +188,7 @@ def test_complete_garmin_mfa_failure(mock_garth_client_class, mock_garth_resume_
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "MFA verification failed: Invalid MFA code"
|
||||
mock_garth_client_class.assert_called_once_with(domain=mfa_state_data["domain"])
|
||||
mock_client_instance._session.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
|
||||
mock_client_instance.sess.cookies.update.assert_called_once_with(mfa_state_data["cookies"])
|
||||
mock_garth_resume_login.assert_called_once()
|
||||
|
||||
# Verify MFA state still exists in DB
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
from src.models.activity import Activity
|
||||
import pytest
|
||||
|
||||
def test_repro_segment_wrong_type(client, db_session):
|
||||
"""
|
||||
Reproduction: Create a segment from a 'running' activity and verify it is not saved as 'cycling'.
|
||||
"""
|
||||
# 1. Setup a fake running activity in DB
|
||||
act_id = 999999999
|
||||
|
||||
# Dummy TCX
|
||||
dummy_tcx = """
|
||||
<TrainingCenterDatabase>
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2018-01-01T00:00:00Z</Id>
|
||||
<Lap StartTime="2018-01-01T00:00:00Z">
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2018-01-01T00:00:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>45.0</LatitudeDegrees>
|
||||
<LongitudeDegrees>-33.0</LongitudeDegrees>
|
||||
</Position>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2018-01-01T00:00:10Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>45.01</LatitudeDegrees>
|
||||
<LongitudeDegrees>-33.01</LongitudeDegrees>
|
||||
</Position>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
</Activity>
|
||||
</Activities>
|
||||
</TrainingCenterDatabase>
|
||||
"""
|
||||
|
||||
act = Activity(
|
||||
id=act_id,
|
||||
garmin_activity_id=str(act_id),
|
||||
activity_name="Test Run",
|
||||
activity_type="running", # Correct Type in DB
|
||||
file_content=dummy_tcx.encode('utf-8'),
|
||||
file_type="tcx"
|
||||
)
|
||||
db_session.add(act)
|
||||
db_session.commit()
|
||||
|
||||
# 2. Call Create Segment Endpoint
|
||||
payload = {
|
||||
"activity_id": act_id,
|
||||
"name": "Test Segment",
|
||||
"start_index": 0,
|
||||
"end_index": 1,
|
||||
"activity_type": "cycling" # Simulate Frontend forcing 'cycling'
|
||||
}
|
||||
|
||||
response = client.post("/api/segments/create", json=payload)
|
||||
assert response.status_code == 200, f"Response: {response.text}"
|
||||
|
||||
data = response.json()
|
||||
seg_id = data['id']
|
||||
from src.models.segment import Segment
|
||||
segment = db_session.query(Segment).filter(Segment.id == seg_id).first()
|
||||
|
||||
print(f"Created Segment Type: {segment.activity_type}")
|
||||
|
||||
# Assert it is running
|
||||
assert segment.activity_type == 'running'
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timezone
|
||||
from src.models.activity import Activity
|
||||
from src.models.segment import Segment
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
return MagicMock()
|
||||
|
||||
def test_optimization_logic(mock_db_session):
|
||||
# Setup test data object
|
||||
activity = MagicMock(spec=Activity)
|
||||
activity.id = 256
|
||||
activity.last_segment_scan_timestamp = None
|
||||
|
||||
# 1. Reset
|
||||
# Logic in original script: manually reset timestamp.
|
||||
# Here we test the optimization logic steps.
|
||||
|
||||
# Scene 1: last_scan is None. Logic should PROCEED.
|
||||
last_scan = activity.last_segment_scan_timestamp
|
||||
assert last_scan is None
|
||||
# If the logic in Job Manager checks `if last_scan >= max_seg_date`, it returns False (Don't skip).
|
||||
|
||||
# Scene 2: last_scan exists and new Segments exist.
|
||||
max_seg_date_mock = datetime(2023, 1, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mock_db_session.query.return_value.scalar.return_value = max_seg_date_mock
|
||||
|
||||
# Set activity last scan to OLDER
|
||||
activity.last_segment_scan_timestamp = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Check Logic
|
||||
should_skip = (activity.last_segment_scan_timestamp >= max_seg_date_mock)
|
||||
assert should_skip is False
|
||||
|
||||
# Scene 3: last_scan is NEWER
|
||||
activity.last_segment_scan_timestamp = datetime(2023, 1, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
should_skip = (activity.last_segment_scan_timestamp >= max_seg_date_mock)
|
||||
assert should_skip is True
|
||||
74
FitnessSync/backend/tests/integration/test_ui_rendering.py
Normal file
74
FitnessSync/backend/tests/integration/test_ui_rendering.py
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
import pytest
|
||||
|
||||
def test_activity_view_render(client):
|
||||
"""
|
||||
Test that the activity view page renders the HTML shell correctly
|
||||
and contains the necessary container elements for JS to populate.
|
||||
"""
|
||||
# Requires an activity ID path parameter, but doesn't validate it in the route handler
|
||||
response = client.get("/activity/12345")
|
||||
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
# Verify Title/Header placeholders
|
||||
assert 'id="act-name"' in html
|
||||
assert 'id="act-time"' in html
|
||||
|
||||
# Verify Map Container
|
||||
assert 'id="map"' in html
|
||||
|
||||
# Verify Chart Container
|
||||
assert 'id="streams-chart"' in html
|
||||
|
||||
# Verify Stats Grid
|
||||
assert 'class="stats-grid"' in html
|
||||
assert 'id="metric-dist"' in html
|
||||
assert 'id="metric-dur"' in html
|
||||
|
||||
# Verify Metrics Section
|
||||
assert 'class="metrics-section"' in html
|
||||
assert 'id="m-avg-pwr"' in html
|
||||
|
||||
def test_segment_view_render(client):
|
||||
"""
|
||||
Test that the segments page renders correctly with the segments table
|
||||
and hidden modals for viewing and comparing segments.
|
||||
"""
|
||||
response = client.get("/segments")
|
||||
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
# Verify Page Title
|
||||
assert "Segments" in html
|
||||
|
||||
# Verify Segments Table
|
||||
assert 'id="segments-table"' in html
|
||||
|
||||
# Verify Create Modal Trigger
|
||||
assert 'data-bs-target="#createSegmentModal"' in html
|
||||
|
||||
def test_comparison_modals_render(client):
|
||||
"""
|
||||
Test that the Segment Comparison and Details modals are present in the DOM
|
||||
of the segments page.
|
||||
"""
|
||||
response = client.get("/segments")
|
||||
assert response.status_code == 200
|
||||
html = response.text
|
||||
|
||||
# Verify View Modal & Attributes
|
||||
assert 'id="viewSegmentModal"' in html
|
||||
assert 'id="seg-map"' in html
|
||||
assert 'id="elevationChart"' in html
|
||||
assert 'id="leaderboard-table"' in html
|
||||
|
||||
# Verify Comparison Modal & Attributes
|
||||
assert 'id="compareModal"' in html
|
||||
assert 'Effort Comparison' in html
|
||||
assert 'id="comparison-table"' in html
|
||||
assert 'id="comparisonChart"' in html
|
||||
Reference in New Issue
Block a user