Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: backend-tests

on:
push:
pull_request:

jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install backend with dev extras
run: pip install -e 'backend[dev]'
- name: Run tests
run: python -m pytest -q
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ docker compose up --build
- **Statistics** — Overall totals, per-year trends, per-bike breakdowns, longest ride tracking
- **Ride editing** — Edit name, type, description, and bike assignment directly in the UI

## Development

```bash
do help # list commands
do test # run the backend test suite (tests/backend/, pytest)
do serve [port] # start the API (default port 8000)
do ingest <export> # ingest a Strava bulk export
do docker # docker compose up --build
```

Backend tests live in `tests/backend/` and are configured by the root `pytest.ini`
(the `do` script reuses `backend/.venv`). CI runs the same suite on every push/PR
(`.github/workflows/backend-tests.yml`). Tests never touch the real `data/`
directory.

## Layout

```
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies = [
]

[project.optional-dependencies]
dev = ["pytest>=8.0"]
dev = ["pytest>=8.0", "httpx>=0.27"]

[tool.setuptools.packages.find]
where = ["."]
Expand Down
72 changes: 22 additions & 50 deletions do
Original file line number Diff line number Diff line change
@@ -1,58 +1,30 @@
#!/bin/bash
#!/usr/bin/env bash
# Convenience entry point for common development tasks.
set -euo pipefail
cd "$(dirname "$0")"

PID_FILE=".do.pids"
PY=backend/.venv/bin/python

init() {
echo "Initializing backend environment..."
python3 -m venv backend/.venv
./backend/.venv/bin/pip install -e backend

echo "Installing frontend dependencies..."
(cd frontend && npm install)

echo "Initialization complete."
}

start() {
echo "Starting backend..."
# Run uvicorn in the background using the venv's python
(cd backend && ./.venv/bin/python3 -m uvicorn app.main:app --port 8000) &
echo $! >> $PID_FILE

echo "Starting frontend..."
(cd frontend && npm run dev) &
echo $! >> $PID_FILE

echo "Application started. PIDs saved to $PID_FILE"
}

stop() {
if [ -f "$PID_FILE" ]; then
echo "Stopping application..."
while read -r pid; do
# Kill the process and its children
pkill -P "$pid" 2>/dev/null
kill "$pid" 2>/dev/null
done < "$PID_FILE"
rm "$PID_FILE"
echo "Application stopped."
else
echo "No running processes found in $PID_FILE."
fi
}

case "$1" in
init)
init
cmd="${1:-help}"
case "$cmd" in
test)
"$PY" -m pytest -q
;;
ingest)
"$PY" -m app.ingest "${2:?usage: do ingest <strava_export_dir>}"
;;
serve)
exec "$PY" -m uvicorn app.main:app --port "${2:-8000}"
;;
start)
start
docker)
docker compose up --build
;;
stop)
stop
help)
grep -E '^ [a-z]+\)' "$0" | sed 's/^ \([a-z]*\).*/\1/' | paste -sd ' ' -
echo "usage: do <command> [args] (do help for commands: test, ingest, serve, docker, help)"
;;
*)
echo "Usage: $0 {init|start|stop}"
echo "unknown command: $cmd (try: do help)" >&2
exit 1
;;
esac
esac
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = . backend
36 changes: 36 additions & 0 deletions tests/backend/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Shared fixtures for backend tests.

Tests must never touch the real ``data/`` directory: every fixture that needs
storage points the app's ``settings`` at a throwaway temp dir instead.
"""
from __future__ import annotations

import pytest
from fastapi.testclient import TestClient

from app.config import settings
from app.services.streams import load_stream


@pytest.fixture()
def data_dir(tmp_path, monkeypatch):
"""Point the app at a throwaway data dir (DB + Parquet streams)."""
d = tmp_path / "data"
d.mkdir()
(d / "streams").mkdir()
# settings.data_dir is a plain attribute; the db_path / streams_dir
# properties derive from it, so patching it redirects everything.
monkeypatch.setattr(settings, "data_dir", d)
# load_stream is lru_cached on (activity_id, parquet_path) - a process-global
# cache that ignores data_dir. Clear it so no test sees another test's data.
load_stream.cache_clear()
return d


@pytest.fixture()
def api_client(data_dir):
"""TestClient with startup events run (init_db) against the temp data dir."""
from app.main import app

with TestClient(app) as client:
yield client
91 changes: 91 additions & 0 deletions tests/backend/parsers/test_parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Parser tests: GPX and TCX parsing, plus parse_one dispatch."""
from __future__ import annotations

from pathlib import Path

import pytest

from app.ingest import parse_one
from app.parsers.gpx import parse_gpx
from app.parsers.tcx import parse_tcx

GPX = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="cycling-view-test" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>Synthetic GPX ride</name>
<trkseg>
<trkpt lat="48.85000" lon="2.35000"><time>2026-01-01T10:00:00Z</time><ele>100</ele></trkpt>
<trkpt lat="48.85011" lon="2.35011"><time>2026-01-01T10:00:01Z</time><ele>101</ele></trkpt>
<trkpt lat="48.85022" lon="2.35022"><time>2026-01-01T10:00:02Z</time><ele>102</ele></trkpt>
<trkpt lat="48.85033" lon="2.35033"><time>2026-01-01T10:00:03Z</time><ele>103</ele></trkpt>
<trkpt lat="48.85044" lon="2.35044"><time>2026-01-01T10:00:04Z</time><ele>104</ele></trkpt>
</trkseg>
</trk>
</gpx>
"""

GPX_EMPTY = """<?xml version="1.0"?>
<gpx version="1.1" creator="cycling-view-test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>No points</name><trkseg></trkseg></trk>
</gpx>
"""

TCX = """<?xml version="1.0"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Biking">
<Id>2026-01-01T11:00:00Z</Id>
<Lap StartTime="2026-01-01T11:00:00Z">
<Track>
<Trackpoint><Time>2026-01-01T11:00:00Z</Time><Position><LatitudeDegrees>48.85000</LatitudeDegrees><LongitudeDegrees>2.35000</LongitudeDegrees></Position><AltitudeMeters>100</AltitudeMeters></Trackpoint>
<Trackpoint><Time>2026-01-01T11:00:01Z</Time><Position><LatitudeDegrees>48.85011</LatitudeDegrees><LongitudeDegrees>2.35011</LongitudeDegrees></Position><AltitudeMeters>101</AltitudeMeters></Trackpoint>
<Trackpoint><Time>2026-01-01T11:00:02Z</Time><Position><LatitudeDegrees>48.85022</LatitudeDegrees><LongitudeDegrees>2.35022</LongitudeDegrees></Position><AltitudeMeters>102</AltitudeMeters></Trackpoint>
<Trackpoint><Time>2026-01-01T11:00:03Z</Time><Position><LatitudeDegrees>48.85033</LatitudeDegrees><LongitudeDegrees>2.35033</LongitudeDegrees></Position><AltitudeMeters>103</AltitudeMeters></Trackpoint>
</Track>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""


def _write(tmp_path: Path, name: str, content: str) -> Path:
p = tmp_path / name
p.write_text(content, encoding="utf-8")
return p


def test_parse_gpx_returns_one_row_per_trackpoint(tmp_path) -> None:
df = parse_gpx(_write(tmp_path, "a.gpx", GPX))
assert not df.empty
assert len(df) == 5
assert "t" in df.columns


def test_parse_gpx_no_points_returns_empty(tmp_path) -> None:
df = parse_gpx(_write(tmp_path, "empty.gpx", GPX_EMPTY))
assert df.empty


def test_parse_tcx_returns_one_row_per_trackpoint(tmp_path) -> None:
df = parse_tcx(_write(tmp_path, "b.tcx", TCX))
assert not df.empty
assert len(df) == 4
assert "t" in df.columns


def test_parse_one_dispatches_by_extension(tmp_path) -> None:
gpx = _write(tmp_path, "a.gpx", GPX)
tcx = _write(tmp_path, "b.tcx", TCX)
assert len(parse_one(gpx)) == 5
assert len(parse_one(tcx)) == 4


def test_parse_one_rejects_unsupported_files(tmp_path) -> None:
txt = _write(tmp_path, "notes.txt", "hello")
with pytest.raises(ValueError):
parse_one(txt)


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
62 changes: 62 additions & 0 deletions tests/backend/routers/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Fixtures for API router tests: seeded SQLite + matching Parquet stream.

Everything stays inside the temp data dir set up by the root conftest —
the real data/ directory is never touched.
"""
from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from app.db import connect, init_db

ACTIVITY_ID = 1
ACTIVITY_START = "2026-05-01T09:00:00Z"
RIDE_SECONDS = 5400 # 90 minutes of 1 Hz data


@pytest.fixture()
def seeded(api_client, data_dir):
"""TestClient against a DB with one ride (power + geo) and its Parquet file."""
init_db()
conn = connect()
conn.execute(
"""
INSERT INTO activities (
id, start_time, name, type, description,
distance_m, elapsed_s, moving_s,
avg_speed_ms, max_speed_ms, avg_hr, max_hr,
avg_power, max_power, np_power,
has_geo, has_power, point_count
) VALUES (
?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?,
?, ?, ?,
1, 1, ?
)
""",
(
ACTIVITY_ID, ACTIVITY_START, "Morning Ride", "Ride", "test ride",
42_000.0, float(RIDE_SECONDS), 5_200.0,
7.78, 12.5, 150.0, 180.0,
250.0, 600.0, 270.0,
RIDE_SECONDS,
),
)
conn.commit()
conn.close()

df = pd.DataFrame(
{
"t": pd.date_range("2026-05-01 09:00:00+00:00", periods=RIDE_SECONDS, freq="s"),
"power": 250.0,
"heart_rate": 150.0,
"speed": 8.0,
"altitude": 100.0,
"distance": np.arange(RIDE_SECONDS, dtype=float) * 8.0,
}
)
df.to_parquet(data_dir / "streams" / f"{ACTIVITY_ID}.parquet", index=False)
return api_client
64 changes: 64 additions & 0 deletions tests/backend/routers/test_rides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""API tests for the rides router (list, detail, streams, update, 404s)."""
from __future__ import annotations

import pytest

from tests.backend.routers.conftest import ACTIVITY_ID


def test_rides_list_contains_seeded_ride(seeded) -> None:
res = seeded.get(f"/api/rides")
assert res.status_code == 200
assert "Morning Ride" in res.text


def test_ride_detail(seeded) -> None:
res = seeded.get(f"/api/rides/{ACTIVITY_ID}")
assert res.status_code == 200
body = res.json()
assert body["name"] == "Morning Ride"
assert body["distance_m"] == pytest.approx(42_000.0)
assert body["avg_power"] == pytest.approx(250.0)


def test_ride_detail_missing_is_404(seeded) -> None:
assert seeded.get("/api/rides/999").status_code == 404


def _field_arrays(body: dict) -> dict:
"""Field arrays live either under a `streams` key or at the top level."""
return body["streams"] if "streams" in body else body


def test_ride_streams_downsampled(seeded) -> None:
res = seeded.get(
f"/api/rides/{ACTIVITY_ID}/streams",
params={"fields": "power,heart_rate", "n_points": 100},
)
assert res.status_code == 200
body = res.json()
assert body["activity_id"] == ACTIVITY_ID
# t is relative to ride start; every field array aligns with it.
assert body["t"][0] == 0.0
assert len(body["t"]) <= 100
fields = _field_arrays(body)
for field in ("power", "heart_rate"):
assert len(fields[field]) == len(body["t"])
# Constant 250W / 150bpm in the fixture must survive downsampling.
assert fields["power"][0] == pytest.approx(250.0)
assert fields["heart_rate"][0] == pytest.approx(150.0)


def test_update_ride(seeded) -> None:
res = seeded.patch(
f"/api/rides/{ACTIVITY_ID}",
json={"name": "Evening Ride", "description": "renamed"},
)
assert res.status_code == 200
detail = seeded.get(f"/api/rides/{ACTIVITY_ID}").json()
assert detail["name"] == "Evening Ride"
assert detail["description"] == "renamed"


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
Loading
Loading