feat: Initial implementation of FitTrack Report Generator

This commit introduces the initial version of the FitTrack Report Generator, a FastAPI application for analyzing workout files.

Key features include:
- Parsing of FIT, TCX, and GPX workout files.
- Analysis of power, heart rate, speed, and elevation data.
- Generation of summary reports and charts.
- REST API for single and batch workout analysis.

The project structure has been set up with a `src` directory for core logic, an `api` directory for the FastAPI application, and a `tests` directory for unit, integration, and contract tests.

The development workflow is configured to use Docker and modern Python tooling.
This commit is contained in:
2025-10-11 09:54:13 -07:00
parent 6643a64ff0
commit 9e0bd322d3
152 changed files with 25695 additions and 49 deletions

View File

@@ -0,0 +1,18 @@
import pytest
from fastapi.testclient import TestClient
from api.main import app
client = TestClient(app)
def test_analyze_workout_endpoint_exists():
response = client.post("/api/analyze/workout")
# Expecting a 422 Unprocessable Entity because no file is provided
# This confirms the endpoint is routed and expects input
assert response.status_code == 422 or response.status_code == 400
def test_analyze_workout_requires_file():
response = client.post("/api/analyze/workout", data={})
assert response.status_code == 422
assert "file" in response.json()["detail"][0]["loc"]
# More detailed tests will be added once the actual implementation is in place

View File

@@ -0,0 +1,101 @@
import pytest
from fastapi.testclient import TestClient
from api.main import app
from uuid import uuid4
from unittest.mock import patch
import zipfile
import io
client = TestClient(app)
def create_zip_file(file_names_and_content):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, content in file_names_and_content.items():
zf.writestr(name, content)
zip_buffer.seek(0)
return zip_buffer
@patch('src.db.session.get_db')
@patch('src.core.batch_processor.BatchProcessor')
def test_analyze_batch_success(mock_batch_processor_cls, mock_get_db):
mock_db_session = mock_get_db.return_value
mock_batch_processor_instance = mock_batch_processor_cls.return_value
mock_batch_processor_instance.process_zip_file.return_value = [
{"analysis_id": str(uuid4()), "file_name": "workout1.fit", "status": "completed"},
{"analysis_id": str(uuid4()), "file_name": "workout2.tcx", "status": "completed"}
]
zip_content = create_zip_file({"workout1.fit": b"dummy_fit_content", "workout2.tcx": b"dummy_tcx_content"})
response = client.post(
"/api/analyze/batch",
files={"zip_file": ("workouts.zip", zip_content.getvalue(), "application/zip")},
data={
"user_id": str(uuid4()),
"ftp_value": 250.0
}
)
assert response.status_code == 200
response_json = response.json()
assert "batch_id" in response_json
assert response_json["status"] == "completed"
assert response_json["total_files"] == 2
assert "results" in response_json
assert len(response_json["results"]) == 2
assert mock_batch_processor_instance.process_zip_file.called
@patch('src.db.session.get_db')
@patch('src.core.batch_processor.BatchProcessor')
def test_analyze_batch_empty_zip(mock_batch_processor_cls, mock_get_db):
zip_content = create_zip_file({})
response = client.post(
"/api/analyze/batch",
files={"zip_file": ("empty.zip", zip_content.getvalue(), "application/zip")}
)
assert response.status_code == 400
assert response.json()["code"] == "EMPTY_ZIP_FILE"
@patch('src.db.session.get_db')
@patch('src.core.batch_processor.BatchProcessor')
def test_analyze_batch_partial_failure(mock_batch_processor_cls, mock_get_db):
mock_db_session = mock_get_db.return_value
mock_batch_processor_instance = mock_batch_processor_cls.return_value
mock_batch_processor_instance.process_zip_file.return_value = [
{"analysis_id": str(uuid4()), "file_name": "workout1.fit", "status": "completed"},
{"file_name": "workout_bad.fit", "status": "failed", "error_message": "Corrupted file"}
]
zip_content = create_zip_file({"workout1.fit": b"dummy_fit_content", "workout_bad.fit": b"bad_content"})
response = client.post(
"/api/analyze/batch",
files={"zip_file": ("workouts.zip", zip_content.getvalue(), "application/zip")}
)
assert response.status_code == 200
response_json = response.json()
assert response_json["status"] == "completed_with_errors"
assert response_json["total_files"] == 2
assert len(response_json["results"]) == 2
assert any(r["status"] == "failed" for r in response_json["results"])
@patch('src.db.session.get_db')
@patch('src.core.batch_processor.BatchProcessor')
def test_analyze_batch_internal_error(mock_batch_processor_cls, mock_get_db):
mock_batch_processor_cls.side_effect = Exception("Unexpected error")
zip_content = create_zip_file({"workout1.fit": b"dummy_fit_content"})
response = client.post(
"/api/analyze/batch",
files={"zip_file": ("workouts.zip", zip_content.getvalue(), "application/zip")}
)
assert response.status_code == 500
assert response.json()["code"] == "INTERNAL_SERVER_ERROR"

View File

@@ -0,0 +1,86 @@
import pytest
from fastapi.testclient import TestClient
from api.main import app
from uuid import uuid4
from unittest.mock import patch
client = TestClient(app)
@pytest.fixture
def mock_workout_analysis():
# Mock a WorkoutAnalysis object that would be returned by the database
class MockWorkoutAnalysis:
def __init__(self, analysis_id, chart_paths):
self.id = analysis_id
self.chart_paths = chart_paths
return MockWorkoutAnalysis(uuid4(), {
"power_curve": "/tmp/power_curve.png",
"elevation_profile": "/tmp/elevation_profile.png",
"zone_distribution_power": "/tmp/zone_distribution_power.png",
"zone_distribution_hr": "/tmp/zone_distribution_hr.png",
"zone_distribution_speed": "/tmp/zone_distribution_speed.png"
})
@patch('src.db.session.get_db')
@patch('src.core.chart_generator.ChartGenerator')
def test_get_analysis_charts_success(mock_chart_generator, mock_get_db, mock_workout_analysis):
# Mock the database session to return our mock_workout_analysis
mock_db_session = mock_get_db.return_value
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_workout_analysis
# Mock the ChartGenerator to simulate chart generation
mock_chart_instance = mock_chart_generator.return_value
mock_chart_instance.generate_power_curve_chart.return_value = None
mock_chart_instance.generate_elevation_profile_chart.return_value = None
mock_chart_instance.generate_zone_distribution_chart.return_value = None
# Create dummy chart files for the test
for chart_type, path in mock_workout_analysis.chart_paths.items():
with open(path, "wb") as f:
f.write(b"dummy_png_content")
chart_type = "power_curve"
response = client.get(f"/api/analysis/{mock_workout_analysis.id}/charts?chart_type={chart_type}")
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.content == b"dummy_png_content"
@patch('src.db.session.get_db')
def test_get_analysis_charts_not_found(mock_get_db):
mock_db_session = mock_get_db.return_value
mock_db_session.query.return_value.filter.return_value.first.return_value = None
analysis_id = uuid4()
chart_type = "power_curve"
response = client.get(f"/api/analysis/{analysis_id}/charts?chart_type={chart_type}")
assert response.status_code == 404
assert response.json()["code"] == "ANALYSIS_NOT_FOUND"
@patch('src.db.session.get_db')
def test_get_analysis_charts_chart_type_not_found(mock_get_db, mock_workout_analysis):
mock_db_session = mock_get_db.return_value
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_workout_analysis
# Remove the chart path for the requested type to simulate not found
mock_workout_analysis.chart_paths.pop("power_curve")
chart_type = "power_curve"
response = client.get(f"/api/analysis/{mock_workout_analysis.id}/charts?chart_type={chart_type}")
assert response.status_code == 404
assert response.json()["code"] == "CHART_NOT_FOUND"
@patch('src.db.session.get_db')
def test_get_analysis_charts_file_not_found(mock_get_db, mock_workout_analysis):
mock_db_session = mock_get_db.return_value
mock_db_session.query.return_value.filter.return_value.first.return_value = mock_workout_analysis
# Ensure the dummy file is not created to simulate file not found
chart_type = "power_curve"
response = client.get(f"/api/analysis/{mock_workout_analysis.id}/charts?chart_type={chart_type}")
assert response.status_code == 500
assert response.json()["code"] == "CHART_FILE_ERROR"