diff --git a/.env.example b/.env.example index 608524d..b956598 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ POLL_INTERVAL_SECONDS=3600 DATA_DIR=/data LOG_LEVEL=INFO DRY_RUN=true +DASHBOARD_HOST=0.0.0.0 +DASHBOARD_PORT=8080 MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/ MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities diff --git a/README.md b/README.md index 8c76803..398f2f1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,14 @@ docker compose logs -f sync The default polling interval is hourly. +Open the local dashboard: + +```text +http://localhost:8080/ +``` + +It shows the last sync check state and the last five uploaded Garmin activities. + ## Configuration Required values: @@ -61,6 +69,8 @@ POLL_INTERVAL_SECONDS=3600 DATA_DIR=/data LOG_LEVEL=INFO DRY_RUN=false +DASHBOARD_HOST=0.0.0.0 +DASHBOARD_PORT=8080 MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/ MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/ MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES diff --git a/src/mywhoosh_garmin_sync/config.py b/src/mywhoosh_garmin_sync/config.py index 186aba9..0ec0f4f 100644 --- a/src/mywhoosh_garmin_sync/config.py +++ b/src/mywhoosh_garmin_sync/config.py @@ -57,6 +57,8 @@ class Settings: db_path: Path log_level: str dry_run: bool + dashboard_host: str + dashboard_port: int mywhoosh_login_url: str mywhoosh_activity_url: str @@ -106,6 +108,8 @@ class Settings: db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))), log_level=os.getenv("LOG_LEVEL", "INFO").upper(), dry_run=_bool_env("DRY_RUN", False), + dashboard_host=os.getenv("DASHBOARD_HOST", "0.0.0.0"), + dashboard_port=_int_env("DASHBOARD_PORT", 8080), mywhoosh_login_url=os.getenv( "MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/" ), diff --git a/src/mywhoosh_garmin_sync/dashboard.py b/src/mywhoosh_garmin_sync/dashboard.py new file mode 100644 index 0000000..3ad7e71 --- /dev/null +++ b/src/mywhoosh_garmin_sync/dashboard.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime +from html import escape +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +from .state import ActivityStore, ActivitySummary, SyncCheckSummary + +logger = logging.getLogger(__name__) + + +class DashboardServer: + def __init__(self, store: ActivityStore, host: str, port: int) -> None: + self.store = store + self.host = host + self.port = port + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + def start(self) -> None: + handler = _handler_factory(self.store) + self._server = ThreadingHTTPServer((self.host, self.port), handler) + self._thread = threading.Thread( + target=self._server.serve_forever, + name="dashboard-http", + daemon=True, + ) + self._thread.start() + logger.info("Dashboard listening on http://%s:%s", self.host, self.port) + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + + +def render_dashboard( + latest_check: SyncCheckSummary | None, + recent_uploads: list[ActivitySummary], + refresh_seconds: int = 30, +) -> str: + status_label = _status_label(latest_check) + status_class = _status_class(latest_check) + message = _status_message(latest_check) + checked_at = latest_check.finished_at if latest_check else None + started_at = latest_check.started_at if latest_check else None + rows = "\n".join(_activity_row(activity) for activity in recent_uploads) + if not rows: + rows = """ + + No uploaded activities recorded yet. + + """ + + checked_markup = escape(_format_german_datetime(checked_at) or "Nie") + started_markup = escape(_format_german_datetime(started_at) or "Nie") + count_markup = _count_markup(latest_check) + error_type_markup = _error_type_markup(latest_check) + + return f""" + + + + + + MyWhoosh Garmin Sync + + + +
+

MyWhoosh Garmin Sync

+
+
+ {escape(status_label)} + Auto-refreshes every {refresh_seconds}s +
+

{escape(message)}

+
+ Gestartet: {started_markup} + Beendet: {checked_markup} + {count_markup} + {error_type_markup} +
+
+
+

Last 5 Uploaded Activities

+ + + + + + + + + + + + {rows} + +
TitleUploadedGarmin IDSourceConverted File
+
+
+ + +""" + + +def render_health(latest_check: SyncCheckSummary | None) -> str: + payload = { + "status": latest_check.status if latest_check else "never_checked", + "message": latest_check.message if latest_check else None, + "error_type": latest_check.error_type if latest_check else None, + "finished_at": latest_check.finished_at if latest_check else None, + } + return json.dumps(payload, separators=(",", ":")) + + +def _handler_factory(store: ActivityStore) -> type[BaseHTTPRequestHandler]: + class DashboardRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + path = urlparse(self.path).path + try: + store.initialize() + if path == "/": + latest = store.get_latest_sync_check() + uploads = store.get_recent_uploaded_activities(5) + body = render_dashboard(latest, uploads) + self._send(HTTPStatus.OK, "text/html; charset=utf-8", body) + return + + if path == "/healthz": + body = render_health(store.get_latest_sync_check()) + self._send(HTTPStatus.OK, "application/json", body) + return + + self._send(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", "Not found") + except Exception: + logger.exception("Dashboard request failed") + self._send( + HTTPStatus.INTERNAL_SERVER_ERROR, + "text/plain; charset=utf-8", + "Dashboard error", + ) + + def log_message(self, fmt: str, *args: object) -> None: + logger.debug("Dashboard: " + fmt, *args) + + def _send(self, status: HTTPStatus, content_type: str, body: str) -> None: + encoded = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + return DashboardRequestHandler + + +def _status_label(latest_check: SyncCheckSummary | None) -> str: + if latest_check is None: + return "Never checked" + if latest_check.status == "ok": + return "OK" + if latest_check.status == "problem": + return "Problem" + return "Running" + + +def _status_class(latest_check: SyncCheckSummary | None) -> str: + if latest_check is None: + return "running" + if latest_check.status == "problem": + return "problem" + if latest_check.status == "ok": + return "ok" + return "running" + + +def _status_message(latest_check: SyncCheckSummary | None) -> str: + if latest_check is None: + return "No sync check has finished yet." + if latest_check.message: + return latest_check.message + if latest_check.status == "ok": + return "Last sync check completed successfully." + if latest_check.status == "problem": + return "Last sync check found a problem." + return "Sync check is running." + + +def _count_markup(latest_check: SyncCheckSummary | None) -> str: + if latest_check is None: + return "Downloaded: 0, processed: 0, failed: 0" + return escape( + f"Downloaded: {latest_check.downloaded_count}, " + f"processed: {latest_check.processed_count}, " + f"failed: {latest_check.failed_count}" + ) + + +def _error_type_markup(latest_check: SyncCheckSummary | None) -> str: + if latest_check is None or not latest_check.error_type: + return "" + return f"Problem type: {escape(latest_check.error_type)}" + + +def _activity_row(activity: ActivitySummary) -> str: + title = escape(activity.title or "MyWhoosh activity") + uploaded = escape( + _format_german_datetime(activity.uploaded_at or activity.updated_at) + or activity.uploaded_at + or activity.updated_at + ) + garmin_id = escape(activity.garmin_activity_id or "-") + source = _link(activity.source_url, "Open") if activity.source_url else "-" + converted = escape(activity.converted_path or "-") + return f""" + + {title} + {uploaded} + {garmin_id} + {source} + {converted} + + """ + + +def _link(url: str, label: str) -> str: + safe_url = escape(url, quote=True) + return f'{escape(label)}' + + +def _format_german_datetime(value: str | None) -> str | None: + if not value: + return None + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + + return parsed.strftime("%d.%m.%Y, %H:%M Uhr") diff --git a/src/mywhoosh_garmin_sync/service.py b/src/mywhoosh_garmin_sync/service.py index 24d8d22..7495809 100644 --- a/src/mywhoosh_garmin_sync/service.py +++ b/src/mywhoosh_garmin_sync/service.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio import logging import signal +from dataclasses import dataclass from pathlib import Path from .config import Settings +from .dashboard import DashboardServer from .fit_device import GarminDevice, convert_fit_device from .garmin import GarminUploadBlocked, GarminUploader from .models import DownloadedActivity @@ -15,6 +17,13 @@ from .state import ActivityStore, sha256_file logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class ProcessingResult: + status: str + error_type: str | None = None + message: str | None = None + + class SyncService: def __init__( self, @@ -36,43 +45,102 @@ class SyncService: async def serve(self) -> None: stop_event = asyncio.Event() + dashboard = DashboardServer( + self.store, + host=self.settings.dashboard_host, + port=self.settings.dashboard_port, + ) + dashboard.start() try: - loop = asyncio.get_running_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, stop_event.set) - except (NotImplementedError, RuntimeError): - pass - - logger.info( - "Starting sync loop; interval=%ss dry_run=%s", - self.settings.poll_interval_seconds, - self.settings.dry_run, - ) - while not stop_event.is_set(): try: - await self.run_once() - except Exception: - logger.exception("Sync cycle failed") + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + except (NotImplementedError, RuntimeError): + pass - try: - await asyncio.wait_for( - stop_event.wait(), timeout=self.settings.poll_interval_seconds - ) - except TimeoutError: - continue + logger.info( + "Starting sync loop; interval=%ss dry_run=%s", + self.settings.poll_interval_seconds, + self.settings.dry_run, + ) + while not stop_event.is_set(): + try: + await self.run_once() + except Exception: + logger.exception("Sync cycle failed") + + try: + await asyncio.wait_for( + stop_event.wait(), timeout=self.settings.poll_interval_seconds + ) + except TimeoutError: + continue + finally: + dashboard.stop() async def run_once(self) -> None: self.store.initialize() - downloads = await self.crawler.download_new_activities( - self.store.is_terminal_source - ) - logger.info("Downloaded %d candidate activities", len(downloads)) + check_id = self.store.begin_sync_check() + downloads: list[DownloadedActivity] = [] + processed_count = 0 + failed_count = 0 + first_error_type: str | None = None + first_error_message: str | None = None + try: + downloads = await self.crawler.download_new_activities( + self.store.is_terminal_source + ) + logger.info("Downloaded %d candidate activities", len(downloads)) - for activity in downloads: - await asyncio.to_thread(self._process_activity, activity) + for activity in downloads: + result = await asyncio.to_thread(self._process_activity, activity) + processed_count += 1 + if result.status == "failed": + failed_count += 1 + first_error_type = first_error_type or result.error_type + first_error_message = first_error_message or result.message - def _process_activity(self, activity: DownloadedActivity) -> None: + if failed_count: + message = ( + f"{failed_count} of {processed_count} downloaded activities failed." + ) + if first_error_message: + message = f"{message} First error: {first_error_message}" + self.store.finish_sync_check( + check_id, + status="problem", + message=message, + error_type=first_error_type or "sync_error", + downloaded_count=len(downloads), + processed_count=processed_count, + failed_count=failed_count, + ) + return + + self.store.finish_sync_check( + check_id, + status="ok", + message=_ok_message(len(downloads), processed_count), + error_type=None, + downloaded_count=len(downloads), + processed_count=processed_count, + failed_count=0, + ) + except Exception as exc: + self.store.finish_sync_check( + check_id, + status="problem", + message=str(exc), + error_type=_classify_sync_exception(exc), + downloaded_count=len(downloads), + processed_count=processed_count, + failed_count=failed_count, + ) + raise + + def _process_activity(self, activity: DownloadedActivity) -> ProcessingResult: try: raw_hash = sha256_file(activity.raw_path) self.store.record_downloaded( @@ -88,7 +156,7 @@ class SyncService: activity.source_ref, "Raw file hash was already uploaded from another source.", ) - return + return ProcessingResult("duplicate") converted_path = self._converted_path(activity) result = convert_fit_device(activity.raw_path, converted_path, self.device) @@ -105,29 +173,67 @@ class SyncService: activity.source_ref, "Converted file hash was already uploaded from another source.", ) - return + return ProcessingResult("duplicate") if self.settings.dry_run: logger.info("Dry run enabled; not uploading %s", converted_path) - return + return ProcessingResult("converted") upload = self.uploader.upload(converted_path) if upload.duplicate: self.store.mark_duplicate( activity.source_ref, "Garmin reported a duplicate activity." ) + return ProcessingResult("duplicate") else: self.store.mark_uploaded( activity.source_ref, garmin_activity_id=upload.garmin_activity_id, ) + return ProcessingResult("uploaded") except GarminUploadBlocked as exc: logger.error("Upload blocked: %s", exc) self.store.mark_failed(activity.source_ref, str(exc)) + return ProcessingResult("failed", "garmin_login", str(exc)) except Exception as exc: logger.exception("Failed processing %s", activity.raw_path) self.store.mark_failed(activity.source_ref, str(exc)) + return ProcessingResult( + "failed", + _classify_activity_exception(exc), + str(exc), + ) def _converted_path(self, activity: DownloadedActivity) -> Path: return self.settings.converted_dir / activity.raw_path.name + +def _ok_message(downloaded_count: int, processed_count: int) -> str: + if downloaded_count == 0: + return "OK: no new activities found." + if processed_count == 1: + return "OK: processed 1 activity." + return f"OK: processed {processed_count} activities." + + +def _classify_sync_exception(exc: Exception) -> str: + if isinstance(exc, GarminUploadBlocked): + return "garmin_login" + + text = str(exc).lower() + if any(token in text for token in ("captcha", "bot challenge", "challenge", "cloudflare")): + return "mywhoosh_challenge" + if any(token in text for token in ("mywhoosh", "login", "credential", "mfa", "auth")): + return "mywhoosh_login" + if "garmin" in text: + return "garmin_upload" + return "sync_error" + + +def _classify_activity_exception(exc: Exception) -> str: + text = str(exc).lower() + if any(token in text for token in ("mfa", "login", "auth", "credential")): + return "garmin_login" + if any(token in text for token in ("garmin", "upload", "connect")): + return "garmin_upload" + return "sync_error" diff --git a/src/mywhoosh_garmin_sync/state.py b/src/mywhoosh_garmin_sync/state.py index 6dec7ae..c88e3f0 100644 --- a/src/mywhoosh_garmin_sync/state.py +++ b/src/mywhoosh_garmin_sync/state.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import sqlite3 from contextlib import contextmanager +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Iterator @@ -22,6 +23,30 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +@dataclass(frozen=True) +class ActivitySummary: + source_ref: str + title: str | None + source_url: str | None + converted_path: str | None + garmin_activity_id: str | None + uploaded_at: str | None + updated_at: str + + +@dataclass(frozen=True) +class SyncCheckSummary: + id: int + started_at: str + finished_at: str | None + status: str + message: str | None + error_type: str | None + downloaded_count: int + processed_count: int + failed_count: int + + class ActivityStore: def __init__(self, db_path: Path) -> None: self.db_path = db_path @@ -59,6 +84,124 @@ class ActivityStore: ON activities(converted_sha256) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + message TEXT, + error_type TEXT, + downloaded_count INTEGER NOT NULL DEFAULT 0, + processed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_sync_checks_started_at + ON sync_checks(started_at) + """ + ) + + def begin_sync_check(self) -> int: + with self._connect() as conn: + cursor = conn.execute( + """ + INSERT INTO sync_checks (started_at, status) + VALUES (?, 'running') + """, + (utc_now(),), + ) + return int(cursor.lastrowid) + + def finish_sync_check( + self, + check_id: int, + status: str, + message: str | None, + error_type: str | None, + downloaded_count: int, + processed_count: int, + failed_count: int, + ) -> None: + self._execute( + """ + UPDATE sync_checks + SET finished_at = ?, + status = ?, + message = ?, + error_type = ?, + downloaded_count = ?, + processed_count = ?, + failed_count = ? + WHERE id = ? + """, + ( + utc_now(), + status, + message[:1000] if message else None, + error_type, + downloaded_count, + processed_count, + failed_count, + check_id, + ), + ) + + def get_latest_sync_check(self) -> SyncCheckSummary | None: + row = self._fetch_one( + """ + SELECT id, started_at, finished_at, status, message, error_type, + downloaded_count, processed_count, failed_count + FROM sync_checks + ORDER BY started_at DESC, id DESC + LIMIT 1 + """, + (), + ) + if row is None: + return None + return SyncCheckSummary( + id=row["id"], + started_at=row["started_at"], + finished_at=row["finished_at"], + status=row["status"], + message=row["message"], + error_type=row["error_type"], + downloaded_count=row["downloaded_count"], + processed_count=row["processed_count"], + failed_count=row["failed_count"], + ) + + def get_recent_uploaded_activities(self, limit: int = 5) -> list[ActivitySummary]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT source_ref, title, source_url, converted_path, + garmin_activity_id, uploaded_at, updated_at + FROM activities + WHERE status = 'uploaded' + ORDER BY uploaded_at DESC, updated_at DESC, source_ref DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + return [ + ActivitySummary( + source_ref=row["source_ref"], + title=row["title"], + source_url=row["source_url"], + converted_path=row["converted_path"], + garmin_activity_id=row["garmin_activity_id"], + uploaded_at=row["uploaded_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] def is_terminal_source(self, source_ref: str) -> bool: row = self._fetch_one( @@ -216,4 +359,3 @@ class ActivityStore: ) -> sqlite3.Row | None: with self._connect() as conn: return conn.execute(sql, params).fetchone() - diff --git a/tests/conftest.py b/tests/conftest.py index ba32946..7e89594 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, diff --git a/tests/test_config.py b/tests/test_config.py index a9f3018..017347f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..36f059b --- /dev/null +++ b/tests/test_dashboard.py @@ -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 '' 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"}' + ) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..9b2d632 --- /dev/null +++ b/tests/test_service.py @@ -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 diff --git a/tests/test_state.py b/tests/test_state.py index f6eb740..1e923e2 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -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", + ]