Dashboard eingefügt

This commit is contained in:
Bastian Wagner
2026-05-07 13:40:39 +02:00
parent e0ea9efe92
commit 3e743de61e
11 changed files with 889 additions and 34 deletions

View File

@@ -8,8 +8,6 @@ from mywhoosh_garmin_sync.config import Settings
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
mywhoosh_email="whoosh@example.com",
mywhoosh_password="whoosh-pass",
garmin_email="garmin@example.com",
garmin_password="garmin-pass",
poll_interval_seconds=3600,
@@ -22,6 +20,8 @@ def settings(tmp_path: Path) -> Settings:
db_path=tmp_path / "state.sqlite3",
log_level="INFO",
dry_run=False,
dashboard_host="127.0.0.1",
dashboard_port=8080,
mywhoosh_login_url="https://www.mywhoosh.com/login/",
mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
mywhoosh_headless=True,

View File

@@ -19,6 +19,8 @@ def test_settings_from_env(monkeypatch, tmp_path):
assert settings.raw_dir == tmp_path / "raw"
assert settings.dry_run is True
assert settings.poll_interval_seconds == 123
assert settings.dashboard_host == "0.0.0.0"
assert settings.dashboard_port == 8080
assert settings.target_garmin_product_id == 3578
assert settings.target_garmin_serial_number == 123456
assert settings.mywhoosh_manual_login_wait_seconds == 900

66
tests/test_dashboard.py Normal file
View File

@@ -0,0 +1,66 @@
from mywhoosh_garmin_sync.dashboard import render_dashboard, render_health
from mywhoosh_garmin_sync.state import ActivitySummary, SyncCheckSummary
def test_dashboard_renders_latest_check_and_upload():
html = render_dashboard(
SyncCheckSummary(
id=1,
started_at="2026-05-07T10:00:00+00:00",
finished_at="2026-05-07T10:01:00+00:00",
status="ok",
message="OK: processed 1 activity.",
error_type=None,
downloaded_count=1,
processed_count=1,
failed_count=0,
),
[
ActivitySummary(
source_ref="source-1",
title="Morning Ride",
source_url="https://example.test/ride.fit",
converted_path="/data/converted/ride.fit",
garmin_activity_id="12345",
uploaded_at="2026-05-07T10:01:00+00:00",
updated_at="2026-05-07T10:01:00+00:00",
)
],
)
assert "OK: processed 1 activity." in html
assert "Morning Ride" in html
assert "12345" in html
assert "07.05.2026, 10:01 Uhr" in html
assert "/data/converted/ride.fit" in html
assert '<meta http-equiv="refresh" content="30">' in html
def test_dashboard_renders_empty_state_before_first_check():
html = render_dashboard(None, [])
assert "Never checked" in html
assert "No sync check has finished yet." in html
assert "No uploaded activities recorded yet." in html
def test_health_renders_latest_status():
body = render_health(
SyncCheckSummary(
id=1,
started_at="2026-05-07T10:00:00+00:00",
finished_at="2026-05-07T10:01:00+00:00",
status="problem",
message="Garmin requested MFA",
error_type="garmin_login",
downloaded_count=1,
processed_count=1,
failed_count=1,
)
)
assert body == (
'{"status":"problem","message":"Garmin requested MFA",'
'"error_type":"garmin_login",'
'"finished_at":"2026-05-07T10:01:00+00:00"}'
)

95
tests/test_service.py Normal file
View File

@@ -0,0 +1,95 @@
import asyncio
from pathlib import Path
import pytest
from mywhoosh_garmin_sync.models import DownloadedActivity
from mywhoosh_garmin_sync.service import ProcessingResult, SyncService
from mywhoosh_garmin_sync.state import ActivityStore
class FakeCrawler:
def __init__(self, downloads=None, exc: Exception | None = None):
self.downloads = downloads or []
self.exc = exc
async def download_new_activities(self, should_skip_source):
if self.exc is not None:
raise self.exc
return self.downloads
class FakeUploader:
pass
def test_run_once_records_ok_check_when_no_downloads(settings):
store = ActivityStore(settings.db_path)
service = SyncService(
settings,
crawler=FakeCrawler(),
uploader=FakeUploader(),
store=store,
)
asyncio.run(service.run_once())
latest = store.get_latest_sync_check()
assert latest is not None
assert latest.status == "ok"
assert latest.message == "OK: no new activities found."
assert latest.downloaded_count == 0
assert latest.failed_count == 0
def test_run_once_records_mywhoosh_problem_before_downloads(settings):
store = ActivityStore(settings.db_path)
service = SyncService(
settings,
crawler=FakeCrawler(exc=RuntimeError("MyWhoosh login did not complete")),
uploader=FakeUploader(),
store=store,
)
with pytest.raises(RuntimeError):
asyncio.run(service.run_once())
latest = store.get_latest_sync_check()
assert latest is not None
assert latest.status == "problem"
assert latest.error_type == "mywhoosh_login"
assert latest.message == "MyWhoosh login did not complete"
def test_run_once_records_activity_processing_problem(settings, tmp_path: Path):
raw_path = tmp_path / "activity.fit"
raw_path.write_bytes(b"not used")
activity = DownloadedActivity(
source_ref="source-1",
title="Ride",
url="https://example.test/ride.fit",
raw_path=raw_path,
)
store = ActivityStore(settings.db_path)
service = SyncService(
settings,
crawler=FakeCrawler(downloads=[activity]),
uploader=FakeUploader(),
store=store,
)
service._process_activity = lambda activity: ProcessingResult(
"failed",
"garmin_login",
"Garmin requested MFA but GARMIN_MFA_CODE is not set.",
)
asyncio.run(service.run_once())
latest = store.get_latest_sync_check()
assert latest is not None
assert latest.status == "problem"
assert latest.error_type == "garmin_login"
assert latest.downloaded_count == 1
assert latest.processed_count == 1
assert latest.failed_count == 1
assert "1 of 1 downloaded activities failed" in latest.message

View File

@@ -42,3 +42,62 @@ def test_failed_activity_can_be_retried(tmp_path: Path):
assert store.get_status("source-1") == "failed"
assert store.is_terminal_source("source-1") is False
def test_sync_check_summary_round_trip(tmp_path: Path):
store = ActivityStore(tmp_path / "state.sqlite3")
store.initialize()
check_id = store.begin_sync_check()
store.finish_sync_check(
check_id=check_id,
status="problem",
message="MyWhoosh login did not complete",
error_type="mywhoosh_login",
downloaded_count=2,
processed_count=1,
failed_count=1,
)
latest = store.get_latest_sync_check()
assert latest is not None
assert latest.id == check_id
assert latest.status == "problem"
assert latest.message == "MyWhoosh login did not complete"
assert latest.error_type == "mywhoosh_login"
assert latest.downloaded_count == 2
assert latest.processed_count == 1
assert latest.failed_count == 1
assert latest.finished_at is not None
def test_recent_uploaded_activities_are_limited_and_ordered(tmp_path: Path):
store = ActivityStore(tmp_path / "state.sqlite3")
store.initialize()
for index in range(6):
source_ref = f"source-{index}"
store.record_downloaded(
source_ref=source_ref,
title=f"Ride {index}",
source_url=f"https://example.test/{index}",
raw_path=tmp_path / f"raw-{index}.fit",
raw_sha256=f"raw-hash-{index}",
)
store.mark_converted(
source_ref=source_ref,
converted_path=tmp_path / f"converted-{index}.fit",
converted_sha256=f"converted-hash-{index}",
patched_field_count=index,
)
store.mark_uploaded(source_ref, str(index))
recent = store.get_recent_uploaded_activities(5)
assert [activity.title for activity in recent] == [
"Ride 5",
"Ride 4",
"Ride 3",
"Ride 2",
"Ride 1",
]