INITIAL COMMIT
This commit is contained in:
56
tests/test_analyze_dump.py
Normal file
56
tests/test_analyze_dump.py
Normal file
@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_analyze_dump_finds_and_clones_fixture(tmp_path: Path) -> None:
|
||||
fixture = tmp_path / "coach.json"
|
||||
fixture.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"date": "2026-06-16",
|
||||
"workout": {
|
||||
"workoutName": "Coach Ride",
|
||||
"sportType": {"sportTypeKey": "cycling"},
|
||||
"estimatedDurationInSecs": 60,
|
||||
"workoutSegments": [
|
||||
{
|
||||
"sportType": {"sportTypeKey": "cycling"},
|
||||
"workoutSteps": [
|
||||
{
|
||||
"type": "ExecutableStepDTO",
|
||||
"stepType": {"stepTypeKey": "warmup"},
|
||||
"endCondition": {"conditionTypeKey": "time"},
|
||||
"endConditionValue": 60,
|
||||
"targetType": {"workoutTargetTypeKey": "no.target"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/analyze_dump.py",
|
||||
str(fixture),
|
||||
"--date",
|
||||
"2026-06-16",
|
||||
"--clone-dry-run",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Local clone payload passed validation." in result.stdout
|
||||
assert "GCClone 2026-06-16 Coach Ride" in result.stdout
|
||||
|
||||
57
tests/test_app.py
Normal file
57
tests/test_app.py
Normal file
@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_first_launch_setup_login_and_health(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("APP_PASSWORD", "change-me")
|
||||
monkeypatch.setenv("APP_SECRET_KEY", "test-secret")
|
||||
module = importlib.import_module("garmin_coach_clone.app")
|
||||
app = module.create_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/healthz")
|
||||
assert health.status_code == 200
|
||||
assert health.json()["app_configured"] is False
|
||||
|
||||
response = client.post(
|
||||
"/setup",
|
||||
data={
|
||||
"username": "owner",
|
||||
"password": "very-secret",
|
||||
"confirm_password": "very-secret",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert "gcc_session" in response.headers["set-cookie"]
|
||||
|
||||
dashboard = client.get("/")
|
||||
assert dashboard.status_code == 200
|
||||
assert "Dashboard" in dashboard.text
|
||||
|
||||
search = client.get("/search")
|
||||
assert search.status_code == 200
|
||||
assert "Garmin credentials are not configured" in search.text
|
||||
|
||||
|
||||
def test_env_bootstrap_login(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("APP_USERNAME", "admin")
|
||||
monkeypatch.setenv("APP_PASSWORD", "boot-secret")
|
||||
monkeypatch.setenv("APP_SECRET_KEY", "test-secret")
|
||||
module = importlib.import_module("garmin_coach_clone.app")
|
||||
app = module.create_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
health = client.get("/healthz")
|
||||
assert health.json()["app_configured"] is True
|
||||
response = client.post(
|
||||
"/login",
|
||||
data={"username": "admin", "password": "boot-secret"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
51
tests/test_coach.py
Normal file
51
tests/test_coach.py
Normal file
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from garmin_coach_clone.coach import (
|
||||
best_workout_for_date,
|
||||
find_workout_like_objects,
|
||||
task_workout_for_date,
|
||||
)
|
||||
|
||||
|
||||
def test_best_workout_for_date_requires_date_match() -> None:
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"scheduledDate": "2026-06-17",
|
||||
"workout": {"workoutSegments": [{"workoutSteps": []}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert best_workout_for_date(plan, date(2026, 6, 16)) is None
|
||||
|
||||
|
||||
def test_best_workout_for_date_returns_nested_matched_workout() -> None:
|
||||
workout = {"workoutSegments": [{"workoutSteps": []}]}
|
||||
plan = {"items": [{"date": "2026-06-16", "workout": workout}]}
|
||||
|
||||
assert best_workout_for_date(plan, date(2026, 6, 16)) == workout
|
||||
|
||||
|
||||
def test_find_workout_like_objects_finds_segments_and_steps() -> None:
|
||||
data = {
|
||||
"a": {"workoutSegments": []},
|
||||
"b": [{"workoutSteps": []}],
|
||||
}
|
||||
|
||||
assert len(find_workout_like_objects(data)) == 2
|
||||
|
||||
|
||||
def test_task_workout_for_date_returns_adaptive_task_workout() -> None:
|
||||
workout = {"workoutName": "Sprint", "workoutUuid": "abc"}
|
||||
plan = {
|
||||
"taskList": [
|
||||
{"calendarDate": "2026-06-15", "taskWorkout": {"workoutName": "Base"}},
|
||||
{"calendarDate": "2026-06-16", "taskWorkout": workout},
|
||||
]
|
||||
}
|
||||
|
||||
assert task_workout_for_date(plan, date(2026, 6, 16)) == workout
|
||||
assert task_workout_for_date(plan, date(2026, 6, 17)) is None
|
||||
45
tests/test_redact.py
Normal file
45
tests/test_redact.py
Normal file
@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from garmin_coach_clone.redact import redact
|
||||
|
||||
|
||||
def test_redact_preserves_workout_schema_ids() -> None:
|
||||
data = {
|
||||
"stepType": {"stepTypeId": 1, "stepTypeKey": "warmup"},
|
||||
"targetType": {"workoutTargetTypeId": 1, "workoutTargetTypeKey": "no.target"},
|
||||
"endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
|
||||
}
|
||||
|
||||
assert redact(data) == data
|
||||
|
||||
|
||||
def test_redact_private_account_and_device_fields() -> None:
|
||||
data = {
|
||||
"email": "rider@example.com",
|
||||
"displayName": "Rider",
|
||||
"unitId": 123456,
|
||||
"ownerId": 999,
|
||||
"deviceId": 888,
|
||||
}
|
||||
|
||||
redacted = redact(data)
|
||||
|
||||
assert redacted["email"] == "<redacted>"
|
||||
assert redacted["displayName"] == "<redacted>"
|
||||
assert redacted["unitId"] == "<redacted>"
|
||||
assert redacted["ownerId"] == "<redacted>"
|
||||
assert redacted["deviceId"] == "<redacted>"
|
||||
|
||||
|
||||
def test_redact_sensitive_values_inside_strings() -> None:
|
||||
data = {
|
||||
"message": "Contact rider@example.com with Authorization: Bearer abc.def.ghi",
|
||||
}
|
||||
|
||||
redacted = redact(data)
|
||||
|
||||
assert "rider@example.com" not in redacted["message"]
|
||||
assert "abc.def.ghi" not in redacted["message"]
|
||||
assert "<redacted-email>" in redacted["message"]
|
||||
assert "Bearer <redacted-token>" in redacted["message"]
|
||||
|
||||
33
tests/test_repository.py
Normal file
33
tests/test_repository.py
Normal file
@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from garmin_coach_clone.config import DEFAULT_FIXED_TIMES, load_settings
|
||||
from garmin_coach_clone.db import Database
|
||||
from garmin_coach_clone.repository import Repository, ScheduleConfig, validate_schedule_config
|
||||
|
||||
|
||||
def test_schedule_restore_defaults(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
settings = load_settings()
|
||||
db = Database(tmp_path / "app.db")
|
||||
db.initialize()
|
||||
repo = Repository(db, settings)
|
||||
|
||||
restored = repo.restore_default_schedule()
|
||||
|
||||
assert restored.fixed_times == DEFAULT_FIXED_TIMES
|
||||
assert repo.schedule_config().active_window == "05:00-22:00"
|
||||
|
||||
|
||||
def test_schedule_validation_rejects_bad_window() -> None:
|
||||
with pytest.raises(ValueError, match="active window"):
|
||||
validate_schedule_config(
|
||||
ScheduleConfig(
|
||||
enabled=True,
|
||||
interval_minutes=30,
|
||||
active_window="22:00-05:00",
|
||||
fixed_times=["05:15"],
|
||||
days_ahead=1,
|
||||
)
|
||||
)
|
||||
147
tests/test_sync_service.py
Normal file
147
tests/test_sync_service.py
Normal file
@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from garmin_coach_clone.config import load_settings
|
||||
from garmin_coach_clone.db import Database
|
||||
from garmin_coach_clone.repository import Repository, ScheduleConfig
|
||||
from garmin_coach_clone.sync_service import SyncService, should_run_now
|
||||
from garmin_coach_clone.workouts import build_dummy_cycling_workout, workout_source_hash
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.uploads: list[dict] = []
|
||||
self.scheduled: list[tuple[str, str]] = []
|
||||
self.unscheduled: list[str] = []
|
||||
self.deleted: list[str] = []
|
||||
|
||||
def get_workouts(self, limit: int = 100) -> list[dict]:
|
||||
return []
|
||||
|
||||
def upload_workout(self, payload: dict) -> dict:
|
||||
self.uploads.append(payload)
|
||||
return {"workoutId": f"w{len(self.uploads)}"}
|
||||
|
||||
def schedule_workout(self, workout_id: str, date_s: str) -> dict:
|
||||
self.scheduled.append((workout_id, date_s))
|
||||
return {"scheduledWorkoutId": f"s{len(self.scheduled)}"}
|
||||
|
||||
def unschedule_workout(self, scheduled_id: str) -> None:
|
||||
self.unscheduled.append(scheduled_id)
|
||||
|
||||
def delete_workout(self, workout_id: str) -> None:
|
||||
self.deleted.append(workout_id)
|
||||
|
||||
|
||||
class FakeGarmin:
|
||||
def __init__(self, source: dict | None) -> None:
|
||||
self.client = FakeClient()
|
||||
self.source = source
|
||||
|
||||
def authenticated_client(self) -> FakeClient:
|
||||
return self.client
|
||||
|
||||
def active_adaptive_plan(self, client: FakeClient) -> dict:
|
||||
_ = client
|
||||
return {"trainingPlanId": 1}
|
||||
|
||||
def adaptive_plan_detail(self, client: FakeClient, plan: dict) -> dict:
|
||||
_ = client, plan
|
||||
return {}
|
||||
|
||||
def coach_workout_for_date(
|
||||
self, client: FakeClient, plan_detail: dict, target_date: date
|
||||
) -> tuple[dict | None, dict | None]:
|
||||
_ = client, plan_detail, target_date
|
||||
if self.source is None:
|
||||
return {"restDay": True, "workoutUuid": "rest"}, None
|
||||
return {"restDay": False, "workoutUuid": self.source["workoutUuid"]}, self.source
|
||||
|
||||
|
||||
def test_should_run_now_for_interval_and_fixed_times() -> None:
|
||||
config = ScheduleConfig(
|
||||
enabled=True,
|
||||
interval_minutes=30,
|
||||
active_window="05:00-22:00",
|
||||
fixed_times=["06:15"],
|
||||
days_ahead=1,
|
||||
)
|
||||
|
||||
assert should_run_now(datetime(2026, 6, 16, 6, 0), config)
|
||||
assert should_run_now(datetime(2026, 6, 16, 6, 15), config)
|
||||
assert not should_run_now(datetime(2026, 6, 16, 4, 30), config)
|
||||
|
||||
|
||||
def test_sync_creates_missing_clone(tmp_path, monkeypatch) -> None:
|
||||
service, repo, garmin = _service(tmp_path, monkeypatch, _source("Sprint"))
|
||||
|
||||
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
|
||||
|
||||
assert result["created"] == 1
|
||||
assert garmin.client.uploads
|
||||
mapping = repo.get_clone_mapping("2026-06-16")
|
||||
assert mapping is not None
|
||||
assert mapping["status"] == "scheduled"
|
||||
|
||||
|
||||
def test_sync_skips_unchanged_clone(tmp_path, monkeypatch) -> None:
|
||||
source = _source("Sprint")
|
||||
service, repo, garmin = _service(tmp_path, monkeypatch, source)
|
||||
repo.upsert_clone_mapping(
|
||||
{
|
||||
"scheduled_date": "2026-06-16",
|
||||
"source_uuid": source["workoutUuid"],
|
||||
"source_hash": workout_source_hash(source),
|
||||
"source_name": "Sprint",
|
||||
"clone_workout_id": "w1",
|
||||
"clone_workout_name": "GCClone 2026-06-16 Sprint",
|
||||
"scheduled_workout_id": "s1",
|
||||
"status": "scheduled",
|
||||
"message": "current",
|
||||
}
|
||||
)
|
||||
|
||||
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
|
||||
|
||||
assert result["skipped"] == 1
|
||||
assert garmin.client.uploads == []
|
||||
|
||||
|
||||
def test_rest_day_with_existing_clone_warns_without_delete(tmp_path, monkeypatch) -> None:
|
||||
service, repo, garmin = _service(tmp_path, monkeypatch, None)
|
||||
repo.upsert_clone_mapping(
|
||||
{
|
||||
"scheduled_date": "2026-06-16",
|
||||
"source_uuid": "old",
|
||||
"source_hash": "old",
|
||||
"source_name": "Old",
|
||||
"clone_workout_id": "w1",
|
||||
"clone_workout_name": "GCClone 2026-06-16 Old",
|
||||
"scheduled_workout_id": "s1",
|
||||
"status": "scheduled",
|
||||
"message": "old",
|
||||
}
|
||||
)
|
||||
|
||||
result = service._run_sync("test", [date(2026, 6, 16)], dry_run=False)
|
||||
|
||||
assert result["warnings"] == 1
|
||||
assert garmin.client.unscheduled == []
|
||||
assert garmin.client.deleted == []
|
||||
|
||||
|
||||
def _service(tmp_path, monkeypatch, source: dict | None):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
settings = load_settings()
|
||||
db = Database(tmp_path / "app.db")
|
||||
db.initialize()
|
||||
repo = Repository(db, settings)
|
||||
garmin = FakeGarmin(source)
|
||||
return SyncService(settings, repo, garmin), repo, garmin
|
||||
|
||||
|
||||
def _source(name: str) -> dict:
|
||||
workout = build_dummy_cycling_workout(name)
|
||||
workout["workoutUuid"] = f"uuid-{name}"
|
||||
return workout
|
||||
125
tests/test_workouts.py
Normal file
125
tests/test_workouts.py
Normal file
@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from garmin_coach_clone.workouts import (
|
||||
build_dummy_cycling_workout,
|
||||
calendar_entry_date,
|
||||
calendar_entry_id,
|
||||
calendar_entry_name,
|
||||
clone_workout_payload,
|
||||
estimate_duration,
|
||||
existing_clone_names,
|
||||
find_generated_calendar_entry,
|
||||
find_generated_workout,
|
||||
generated_calendar_entries,
|
||||
generated_workouts,
|
||||
validate_workout_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_dummy_workout_has_expected_duration_and_steps() -> None:
|
||||
workout = build_dummy_cycling_workout("Dummy")
|
||||
|
||||
assert workout["workoutName"] == "Dummy"
|
||||
assert workout["sportType"]["sportTypeKey"] == "cycling"
|
||||
assert estimate_duration(workout) == 14 * 60
|
||||
assert validate_workout_payload(workout) == []
|
||||
|
||||
|
||||
def test_clone_payload_strips_ids_and_sets_prefix() -> None:
|
||||
source = build_dummy_cycling_workout("Coach Original")
|
||||
source["workoutId"] = 123
|
||||
source["ownerId"] = 456
|
||||
source["workoutSegments"][0]["workoutSteps"][0]["stepId"] = 789
|
||||
|
||||
cloned = clone_workout_payload(source, date(2026, 6, 16), "GCClone")
|
||||
|
||||
assert cloned["workoutName"] == "GCClone 2026-06-16 Coach Original"
|
||||
assert "workoutId" not in cloned
|
||||
assert "ownerId" not in cloned
|
||||
assert "stepId" not in cloned["workoutSegments"][0]["workoutSteps"][0]
|
||||
assert validate_workout_payload(cloned) == []
|
||||
|
||||
|
||||
def test_existing_clone_names_filters_by_prefix_and_date() -> None:
|
||||
names = existing_clone_names(
|
||||
[
|
||||
{"workoutName": "GCClone 2026-06-16 Ride"},
|
||||
{"workoutName": "GCClone 2026-06-17 Ride"},
|
||||
{"workoutName": "Other 2026-06-16 Ride"},
|
||||
],
|
||||
date(2026, 6, 16),
|
||||
"GCClone",
|
||||
)
|
||||
|
||||
assert names == ["GCClone 2026-06-16 Ride"]
|
||||
|
||||
|
||||
def test_generated_workouts_filters_by_prefix() -> None:
|
||||
workouts = [
|
||||
{"workoutId": 1, "workoutName": "GCClone 2026-06-16 Ride"},
|
||||
{"workoutId": 2, "workoutName": "GCClone Probe Dummy 2026-06-16"},
|
||||
{"workoutId": 3, "workoutName": "Real Workout"},
|
||||
]
|
||||
|
||||
assert generated_workouts(workouts, "GCClone") == workouts[:2]
|
||||
assert find_generated_workout(workouts, "2", "GCClone") == workouts[1]
|
||||
assert find_generated_workout(workouts, "3", "GCClone") is None
|
||||
|
||||
|
||||
def test_generated_calendar_entries_handle_nested_workout_names() -> None:
|
||||
generated_entry = {
|
||||
"scheduledWorkoutId": 99,
|
||||
"scheduledDate": "2026-06-16",
|
||||
"workout": {"workoutName": "GCClone 2026-06-16 Ride"},
|
||||
}
|
||||
data = {
|
||||
"calendarItems": [
|
||||
generated_entry,
|
||||
{
|
||||
"scheduledWorkoutId": 100,
|
||||
"scheduledDate": "2026-06-16",
|
||||
"workout": {"workoutName": "Real Workout"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
entries = generated_calendar_entries(data, "GCClone")
|
||||
|
||||
assert entries == [generated_entry]
|
||||
assert find_generated_calendar_entry(data, "99", "GCClone") == generated_entry
|
||||
assert find_generated_calendar_entry(data, "100", "GCClone") is None
|
||||
assert calendar_entry_id(generated_entry) == "99"
|
||||
assert calendar_entry_date(generated_entry) == "2026-06-16"
|
||||
assert calendar_entry_name(generated_entry) == "GCClone 2026-06-16 Ride"
|
||||
|
||||
|
||||
def test_validate_workout_payload_rejects_missing_segments() -> None:
|
||||
errors = validate_workout_payload(
|
||||
{
|
||||
"workoutName": "Broken",
|
||||
"sportType": {"sportTypeKey": "cycling"},
|
||||
"workoutSegments": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert "workoutSegments must be a non-empty list" in errors
|
||||
|
||||
|
||||
def test_validate_workout_payload_rejects_non_cycling() -> None:
|
||||
workout = build_dummy_cycling_workout("Broken")
|
||||
workout["sportType"] = {"sportTypeKey": "running"}
|
||||
|
||||
errors = validate_workout_payload(workout)
|
||||
|
||||
assert "sportType must be cycling, got running" in errors
|
||||
|
||||
|
||||
def test_validate_workout_payload_rejects_missing_step_target() -> None:
|
||||
workout = build_dummy_cycling_workout("Broken")
|
||||
workout["workoutSegments"][0]["workoutSteps"][0].pop("targetType")
|
||||
|
||||
errors = validate_workout_payload(workout)
|
||||
|
||||
assert "workoutSegments[1].workoutSteps[1].targetType is missing" in errors
|
||||
Reference in New Issue
Block a user