This commit is contained in:
Bastian Wagner
2026-05-05 19:26:43 +02:00
commit 8d07939527
29 changed files with 2646 additions and 0 deletions

45
tests/conftest.py Normal file
View File

@@ -0,0 +1,45 @@
from pathlib import Path
import pytest
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,
data_dir=tmp_path,
raw_dir=tmp_path / "raw",
converted_dir=tmp_path / "converted",
browser_state_dir=tmp_path / "browser",
mywhoosh_auth_state_path=tmp_path / "mywhoosh_auth_state.json",
garmin_tokenstore=tmp_path / "garmin_tokens",
db_path=tmp_path / "state.sqlite3",
log_level="INFO",
dry_run=False,
dashboard_enabled=True,
dashboard_bind="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,
mywhoosh_timeout_seconds=1,
mywhoosh_max_downloads_per_run=10,
mywhoosh_download_text_hints=["fit", "download"],
mywhoosh_activities_button_text="ACTIVITIES",
mywhoosh_download_button_selector=".btnDownload",
mywhoosh_slow_mo_ms=0,
mywhoosh_manual_login_wait_seconds=0,
mywhoosh_debug_screenshots=False,
mywhoosh_debug_dir=tmp_path / "debug",
garmin_mfa_code=None,
target_garmin_manufacturer_id=1,
target_garmin_product_id=3578,
target_garmin_product_name="Edge 1030 Plus",
target_garmin_serial_number=None,
)

35
tests/test_config.py Normal file
View File

@@ -0,0 +1,35 @@
from pathlib import Path
from mywhoosh_garmin_sync.config import Settings
def test_settings_from_env(monkeypatch, tmp_path):
monkeypatch.setenv("MYWHOOSH_EMAIL", "whoosh@example.com")
monkeypatch.setenv("MYWHOOSH_PASSWORD", "whoosh-pass")
monkeypatch.setenv("GARMIN_EMAIL", "garmin@example.com")
monkeypatch.setenv("GARMIN_PASSWORD", "garmin-pass")
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("DRY_RUN", "true")
monkeypatch.setenv("POLL_INTERVAL_SECONDS", "123")
monkeypatch.setenv("TARGET_GARMIN_SERIAL_NUMBER", "123456")
monkeypatch.setenv("MYWHOOSH_MANUAL_LOGIN_WAIT_SECONDS", "900")
settings = Settings.from_env()
assert settings.data_dir == tmp_path
assert settings.raw_dir == tmp_path / "raw"
assert settings.dry_run is True
assert settings.poll_interval_seconds == 123
assert settings.dashboard_enabled is True
assert settings.dashboard_bind == "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
assert settings.mywhoosh_activities_button_text == "ACTIVITIES"
assert settings.mywhoosh_download_button_selector == ".btnDownload"
assert settings.mywhoosh_auth_state_path == tmp_path / "mywhoosh_auth_state.json"
settings.ensure_directories()
assert Path(settings.raw_dir).is_dir()
assert Path(settings.mywhoosh_debug_dir).is_dir()

119
tests/test_fit_device.py Normal file
View File

@@ -0,0 +1,119 @@
from pathlib import Path
import struct
import pytest
from mywhoosh_garmin_sync.fit_crc import fit_crc
from mywhoosh_garmin_sync.fit_device import (
DeviceFieldValue,
FitFormatError,
GarminDevice,
convert_fit_device,
read_device_field_values,
)
def test_convert_fit_device_patches_metadata_and_crc(tmp_path: Path):
source = tmp_path / "source.fit"
output = tmp_path / "output.fit"
source.write_bytes(_minimal_activity_fit())
result = convert_fit_device(
source,
output,
GarminDevice(product_id=3578, product_name="Edge 1030 Plus", serial_number=42),
)
assert result.patched_field_count == 8
values = read_device_field_values(output)
assert DeviceFieldValue(0, 1, 1) in values
assert DeviceFieldValue(0, 2, 3578) in values
assert DeviceFieldValue(0, 3, 42) in values
assert DeviceFieldValue(0, 8, "Edge 1030 Plus") in values
assert DeviceFieldValue(23, 2, 1) in values
assert DeviceFieldValue(23, 3, 42) in values
assert DeviceFieldValue(23, 4, 3578) in values
assert DeviceFieldValue(23, 27, "Edge 1030 Plus") in values
data = output.read_bytes()
assert struct.unpack_from("<H", data, 12)[0] == fit_crc(data[:12])
assert struct.unpack_from("<H", data, len(data) - 2)[0] == fit_crc(data[:-2])
def test_convert_fit_device_rejects_bad_crc(tmp_path: Path):
source = tmp_path / "bad.fit"
output = tmp_path / "output.fit"
data = bytearray(_minimal_activity_fit())
data[-1] ^= 0xFF
source.write_bytes(data)
with pytest.raises(FitFormatError):
convert_fit_device(source, output)
def _minimal_activity_fit() -> bytes:
data = bytearray()
data.extend(
_definition(
local=0,
global_message=0,
fields=[
(0, 1, 0x00),
(1, 2, 0x84),
(2, 2, 0x84),
(3, 4, 0x8C),
(8, 20, 0x07),
],
)
)
data.extend(b"\x00")
data.extend(struct.pack("<BHHI", 4, 32, 40, 999))
data.extend(_fit_string("MyWhoosh", 20))
data.extend(
_definition(
local=1,
global_message=23,
fields=[
(2, 2, 0x84),
(3, 4, 0x8C),
(4, 2, 0x84),
(27, 20, 0x07),
],
)
)
data.extend(b"\x01")
data.extend(struct.pack("<HIH", 32, 999, 40))
data.extend(_fit_string("Trainer", 20))
header = bytearray(14)
header[0] = 14
header[1] = 0x10
struct.pack_into("<H", header, 2, 0x08)
struct.pack_into("<I", header, 4, len(data))
header[8:12] = b".FIT"
struct.pack_into("<H", header, 12, fit_crc(header[:12]))
fit_file = header + data + b"\x00\x00"
struct.pack_into("<H", fit_file, len(fit_file) - 2, fit_crc(fit_file[:-2]))
return bytes(fit_file)
def _definition(
local: int, global_message: int, fields: list[tuple[int, int, int]]
) -> bytes:
result = bytearray()
result.append(0x40 | local)
result.append(0)
result.append(0)
result.extend(struct.pack("<H", global_message))
result.append(len(fields))
for field_num, size, base_type in fields:
result.extend(bytes([field_num, size, base_type]))
return bytes(result)
def _fit_string(value: str, size: int) -> bytes:
encoded = value.encode("utf-8")[: size - 1] + b"\x00"
return encoded + b"\x00" * (size - len(encoded))

59
tests/test_garmin.py Normal file
View File

@@ -0,0 +1,59 @@
from dataclasses import replace
import pytest
from mywhoosh_garmin_sync.garmin import GarminUploadBlocked, GarminUploader
class FakeGarminClient:
def __init__(self, *args, **kwargs):
self.prompt_mfa = kwargs.get("prompt_mfa")
self.logged_in = False
def login(self, tokenstore=None):
self.logged_in = True
def upload_activity(self, activity_path):
return {"activityId": 12345, "path": activity_path}
class DuplicateGarminClient(FakeGarminClient):
def upload_activity(self, activity_path):
raise RuntimeError("409 duplicate activity already exists")
def test_garmin_uploader_success(settings, tmp_path):
fit = tmp_path / "activity.fit"
fit.write_bytes(b"fit")
uploader = GarminUploader(settings, client_factory=FakeGarminClient)
result = uploader.upload(fit)
assert result.status == "uploaded"
assert result.garmin_activity_id == "12345"
def test_garmin_uploader_duplicate(settings, tmp_path):
fit = tmp_path / "activity.fit"
fit.write_bytes(b"fit")
uploader = GarminUploader(settings, client_factory=DuplicateGarminClient)
result = uploader.upload(fit)
assert result.duplicate is True
assert result.status == "duplicate"
def test_mfa_prompt_blocks_without_code(settings):
uploader = GarminUploader(settings, client_factory=FakeGarminClient)
with pytest.raises(GarminUploadBlocked):
uploader._prompt_mfa()
def test_mfa_prompt_returns_env_code(settings):
settings = replace(settings, garmin_mfa_code="123456")
uploader = GarminUploader(settings, client_factory=FakeGarminClient)
assert uploader._prompt_mfa() == "123456"

58
tests/test_mywhoosh.py Normal file
View File

@@ -0,0 +1,58 @@
from mywhoosh_garmin_sync.mywhoosh import (
_looks_like_challenge_text,
_looks_like_challenge_url,
_looks_like_cookie_accept_label,
_source_ref,
_storage_restore_script,
)
def test_cookie_accept_label_matching():
assert _looks_like_cookie_accept_label("Accept")
assert _looks_like_cookie_accept_label("Accept all cookies")
assert _looks_like_cookie_accept_label("Allow all")
assert _looks_like_cookie_accept_label("I agree")
assert _looks_like_cookie_accept_label("Got it")
assert not _looks_like_cookie_accept_label("Reject all")
assert not _looks_like_cookie_accept_label("Download app")
def test_challenge_text_matching():
assert _looks_like_challenge_text("I'm not a robot")
assert _looks_like_challenge_text("Verify you are human")
assert _looks_like_challenge_text("iframe title recaptcha")
assert _looks_like_challenge_text("Cloudflare security check")
assert not _looks_like_challenge_text("Welcome to your activities")
def test_challenge_url_matching_does_not_flag_plain_login():
assert _looks_like_challenge_url("https://example.test/cdn-cgi/challenge-platform")
assert _looks_like_challenge_url("https://example.test/verify")
assert not _looks_like_challenge_url("https://event.mywhoosh.com/login/")
assert not _looks_like_challenge_url("https://event.mywhoosh.com/user/activities")
def test_source_ref_prefers_href_then_row_text():
assert _source_ref("https://example.test/a.fit", "Download", 0, "Ride A") == _source_ref(
"https://example.test/a.fit", "Download", 99, "Ride B"
)
assert _source_ref(None, "", 0, "Ride A") != _source_ref(None, "", 1, "Ride B")
def test_storage_restore_script_contains_session_storage():
script = _storage_restore_script(
[
{
"origin": "https://event.mywhoosh.com",
"localStorage": [{"name": "token", "value": "local"}],
"sessionStorage": [{"name": "session-token", "value": "session"}],
}
]
)
assert "https://event.mywhoosh.com" in script
assert "localStorage" in script
assert "sessionStorage" in script

44
tests/test_state.py Normal file
View File

@@ -0,0 +1,44 @@
from pathlib import Path
from mywhoosh_garmin_sync.state import ActivityStore
def test_state_transitions_and_hash_lookup(tmp_path: Path):
store = ActivityStore(tmp_path / "state.sqlite3")
store.initialize()
store.record_downloaded(
source_ref="source-1",
title="Ride",
source_url="https://example.test/ride.fit",
raw_path=tmp_path / "raw.fit",
raw_sha256="raw-hash",
)
assert store.get_status("source-1") == "downloaded"
assert store.is_terminal_source("source-1") is False
store.mark_converted(
source_ref="source-1",
converted_path=tmp_path / "converted.fit",
converted_sha256="converted-hash",
patched_field_count=4,
)
assert store.get_status("source-1") == "converted"
assert store.is_uploaded_hash("converted-hash") is False
store.mark_uploaded("source-1", "123")
assert store.get_status("source-1") == "uploaded"
assert store.is_terminal_source("source-1") is True
assert store.is_uploaded_hash("raw-hash") is True
assert store.is_uploaded_hash("converted-hash") is True
def test_failed_activity_can_be_retried(tmp_path: Path):
store = ActivityStore(tmp_path / "state.sqlite3")
store.initialize()
store.record_downloaded("source-1", "Ride", None, tmp_path / "raw.fit", "hash")
store.mark_failed("source-1", "network failed")
assert store.get_status("source-1") == "failed"
assert store.is_terminal_source("source-1") is False