Dashboard eingefügt
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
66
tests/test_dashboard.py
Normal 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
95
tests/test_service.py
Normal 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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user