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:
130
FitnessSync/backend/tests/services/test_service_discovery.py
Normal file
130
FitnessSync/backend/tests/services/test_service_discovery.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import pytest
|
||||
from src.services.discovery import SegmentDiscoveryService
|
||||
from src.models.activity import Activity
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_decimate_points():
|
||||
service = SegmentDiscoveryService(None)
|
||||
|
||||
# 3 points in a line, 20m apart. Min dist 30m.
|
||||
# p0 (0,0) -> 20m -> p1 (20m, 0) -> 20m -> p2 (40m, 0)
|
||||
# Result should be p0, p2.
|
||||
|
||||
# Approx: 0.00018 deg lat ~ 20m
|
||||
points = [
|
||||
[0.0, 0.0],
|
||||
[0.0, 0.00018],
|
||||
[0.0, 0.00036]
|
||||
]
|
||||
|
||||
decimated = service._decimate_points(points, min_dist=30.0)
|
||||
assert len(decimated) == 2
|
||||
assert decimated[0] == [0.0, 0.0]
|
||||
assert decimated[1] == [0.0, 0.00036]
|
||||
|
||||
def test_discovery_integration():
|
||||
# Setup
|
||||
mock_db = MagicMock()
|
||||
service = SegmentDiscoveryService(mock_db)
|
||||
|
||||
# Mock Activity Objects
|
||||
act1 = MagicMock(spec=Activity)
|
||||
act1.id = 101
|
||||
act1.activity_type = "cycling"
|
||||
act1.start_time = datetime(2025,1,1)
|
||||
act1.file_content = b"mock_content"
|
||||
act1.file_type = "fit"
|
||||
|
||||
act2 = MagicMock(spec=Activity)
|
||||
act2.id = 102
|
||||
act2.activity_type = "cycling"
|
||||
act2.start_time = datetime(2025,1,2)
|
||||
act2.file_content = b"mock_content"
|
||||
act2.file_type = "fit"
|
||||
|
||||
# Mock extract_points_from_file to return overlapping paths
|
||||
# They share a segment: (0,0) -> (0, 0.0005) -> (0, 0.0010)
|
||||
# act1 goes further: -> (0, 0.0020)
|
||||
# act2 stops or goes elsewhere
|
||||
|
||||
# Approx 0.0002 deg grid step (~20m)
|
||||
# 0.0000 -> 0.0010 is about 100m. Might be too short for 200m cutoff.
|
||||
# Let's make it longer. 0.0030 deg is ~300m.
|
||||
|
||||
path_shared = [[0.0, y*0.0001] for y in range(30)] # 0 to 0.0029
|
||||
|
||||
service._decimate_points = MagicMock(side_effect=[
|
||||
path_shared, # act1
|
||||
path_shared # act2
|
||||
])
|
||||
|
||||
# Mock DB query
|
||||
# First query is for Activities
|
||||
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [act1, act2]
|
||||
|
||||
# Second query is for Segments (deduplication) - called in _deduplicate_against_db
|
||||
# It calls db.query(Segment).filter(...).all()
|
||||
# We need to distinguish them or just ensure the Segment query returns [].
|
||||
# But current mock chain is generic.
|
||||
# 'query' returns a Mock. Calling 'filter' on it returns SAME/NEW Mock.
|
||||
# If we reuse the same mock chain, it returns [act1, act2].
|
||||
# We should distinguish based on call args or set up side_effect.
|
||||
|
||||
def side_effect_query(model):
|
||||
m = MagicMock()
|
||||
if model == Activity:
|
||||
# Depth 3
|
||||
m.filter.return_value.filter.return_value.filter.return_value.all.return_value = [act1, act2]
|
||||
# Depth 2 (type + start_date)
|
||||
m.filter.return_value.filter.return_value.all.return_value = [act1, act2]
|
||||
# Depth 1 (type only)
|
||||
m.filter.return_value.all.return_value = [act1, act2]
|
||||
else:
|
||||
# Segment
|
||||
m.filter.return_value.all.return_value = []
|
||||
return m
|
||||
|
||||
mock_db.query.side_effect = side_effect_query
|
||||
|
||||
# We need to mock extract_points_from_file at module level or patch it
|
||||
# But since we mocked _decimate_points, extract_points_from_file is called but result ignored/passed to decimate.
|
||||
# Wait, discovery.py calls:
|
||||
# raw_points = extract_points_from_file(...)
|
||||
# simplified = self._decimate_points(raw_points, ...)
|
||||
# So we need to patch extract_points_from_file to avoid error on "mock_content"
|
||||
|
||||
with patch('src.services.discovery.extract_points_from_file', return_value=[[0,0]]) as mock_extract:
|
||||
candidates, _ = service.discover_segments("cycling", datetime(2025,1,1))
|
||||
|
||||
# Assertions
|
||||
# Since we mocked decimate to return identical long paths, they should cluster.
|
||||
# result should contain candidates.
|
||||
# activity_ids should validly contain [101, 102]
|
||||
|
||||
assert len(candidates) >= 1
|
||||
candidate = candidates[0]
|
||||
assert 101 in candidate.activity_ids
|
||||
assert 102 in candidate.activity_ids
|
||||
assert isinstance(candidate.activity_ids[0], int)
|
||||
|
||||
|
||||
def test_connected_components():
|
||||
service = SegmentDiscoveryService(None)
|
||||
|
||||
cells = {
|
||||
(0,0): {1}, (0,1): {1}, (0,2): {1}, # Vertical loose chain
|
||||
(0,3): {1}, (0,4): {1}, (0,5): {1}, # Total 6
|
||||
(10,10): {2}, (10,11): {2} # Separate small cluster (length 2)
|
||||
}
|
||||
|
||||
# Connected components with min size 5
|
||||
# Since we set min cluster size to 5 in implementation
|
||||
|
||||
comps = service._find_connected_components(cells, 0.0002)
|
||||
assert len(comps) == 1
|
||||
assert len(comps[0]) == 6
|
||||
assert (0,0) in comps[0]
|
||||
assert (10,10) not in comps[0]
|
||||
Reference in New Issue
Block a user