136 lines
4.5 KiB
Python
136 lines
4.5 KiB
Python
|
|
"""Path B per-second: pulling original FITs from the API instead of a website export.
|
||
|
|
|
||
|
|
`download_fit` / `backfill_fits` are mocked at the `garth.download` boundary
|
||
|
|
(network-dependent, upstream-deprecated — see ROADMAP test conventions). The FIT
|
||
|
|
extractor is pure and tested directly.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import io
|
||
|
|
import zipfile
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from openrun.ingest import garmin_api as g
|
||
|
|
|
||
|
|
# A FIT header carries the literal b".FIT" at bytes 8..12; payload after is opaque here.
|
||
|
|
FIT_BYTES = b"\x0e\x10\x00\x00\x20\x00\x00\x00.FITdata-goes-here"
|
||
|
|
|
||
|
|
|
||
|
|
def _zip(*members: tuple[str, bytes]) -> bytes:
|
||
|
|
buf = io.BytesIO()
|
||
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
||
|
|
for name, data in members:
|
||
|
|
zf.writestr(name, data)
|
||
|
|
return buf.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
# --- _extract_fit_bytes ----------------------------------------------------
|
||
|
|
|
||
|
|
def test_extract_from_zip():
|
||
|
|
assert g._extract_fit_bytes(_zip(("9.fit", FIT_BYTES))) == FIT_BYTES
|
||
|
|
|
||
|
|
|
||
|
|
def test_extract_from_zip_extensionless_member_picks_largest():
|
||
|
|
blob = _zip(("small", b"x"), ("big", FIT_BYTES))
|
||
|
|
assert g._extract_fit_bytes(blob) == FIT_BYTES
|
||
|
|
|
||
|
|
|
||
|
|
def test_extract_from_bare_fit():
|
||
|
|
assert g._extract_fit_bytes(FIT_BYTES) == FIT_BYTES
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("blob", [None, b"", b"not a fit at all"])
|
||
|
|
def test_extract_rejects_non_fit(blob):
|
||
|
|
assert g._extract_fit_bytes(blob) is None
|
||
|
|
|
||
|
|
|
||
|
|
# --- download_fit ----------------------------------------------------------
|
||
|
|
|
||
|
|
def _seed_activity(conn, aid: int, atype: str = "running") -> None:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO activities (activity_id, activity_type, start_time_gmt, raw, fetched_at) "
|
||
|
|
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||
|
|
(aid, atype, "2026-05-01T10:00:00", "{}"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_fit_writes_and_links(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 42)
|
||
|
|
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("42.fit", FIT_BYTES)))
|
||
|
|
|
||
|
|
ok = g.download_fit(tmp_conn, 42, fit_dir=tmp_path)
|
||
|
|
|
||
|
|
assert ok is True
|
||
|
|
dest = tmp_path / "42.fit"
|
||
|
|
assert dest.read_bytes() == FIT_BYTES
|
||
|
|
row = tmp_conn.execute(
|
||
|
|
"SELECT fit_path FROM activity_fit_files WHERE activity_id = 42"
|
||
|
|
).fetchone()
|
||
|
|
assert row is not None and row[0].endswith("42.fit")
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_fit_skips_when_already_linked(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 7)
|
||
|
|
(tmp_path / "7.fit").write_bytes(FIT_BYTES)
|
||
|
|
g.record_link(tmp_conn, 7, tmp_path / "7.fit")
|
||
|
|
|
||
|
|
calls = []
|
||
|
|
monkeypatch.setattr(g.garth, "download", lambda path: calls.append(path) or b"")
|
||
|
|
|
||
|
|
assert g.download_fit(tmp_conn, 7, fit_dir=tmp_path) is True
|
||
|
|
assert calls == [] # no network call when on disk + linked
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_fit_force_redownloads(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 7)
|
||
|
|
(tmp_path / "7.fit").write_bytes(b"stale")
|
||
|
|
g.record_link(tmp_conn, 7, tmp_path / "7.fit")
|
||
|
|
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("7.fit", FIT_BYTES)))
|
||
|
|
|
||
|
|
assert g.download_fit(tmp_conn, 7, fit_dir=tmp_path, force=True) is True
|
||
|
|
assert (tmp_path / "7.fit").read_bytes() == FIT_BYTES
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_fit_returns_false_on_empty_response(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 99)
|
||
|
|
monkeypatch.setattr(g.garth, "download", lambda path: None)
|
||
|
|
|
||
|
|
assert g.download_fit(tmp_conn, 99, fit_dir=tmp_path) is False
|
||
|
|
assert tmp_conn.execute(
|
||
|
|
"SELECT 1 FROM activity_fit_files WHERE activity_id = 99"
|
||
|
|
).fetchone() is None
|
||
|
|
|
||
|
|
|
||
|
|
# --- backfill_fits ---------------------------------------------------------
|
||
|
|
|
||
|
|
def test_backfill_only_targets_unlinked(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 1)
|
||
|
|
_seed_activity(tmp_conn, 2)
|
||
|
|
(tmp_path / "1.fit").write_bytes(FIT_BYTES)
|
||
|
|
g.record_link(tmp_conn, 1, tmp_path / "1.fit") # 1 already has a FIT
|
||
|
|
|
||
|
|
pulled = []
|
||
|
|
monkeypatch.setattr(
|
||
|
|
g.garth, "download",
|
||
|
|
lambda path: pulled.append(path) or _zip(("x.fit", FIT_BYTES)),
|
||
|
|
)
|
||
|
|
|
||
|
|
got = g.backfill_fits(tmp_conn, fit_dir=tmp_path)
|
||
|
|
|
||
|
|
assert got == 1
|
||
|
|
assert pulled == ["/download-service/files/activity/2"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_backfill_respects_type_filter(tmp_conn, tmp_path, monkeypatch):
|
||
|
|
_seed_activity(tmp_conn, 10, atype="running")
|
||
|
|
_seed_activity(tmp_conn, 11, atype="cycling")
|
||
|
|
monkeypatch.setattr(g.garth, "download", lambda path: _zip(("x.fit", FIT_BYTES)))
|
||
|
|
|
||
|
|
got = g.backfill_fits(tmp_conn, fit_dir=tmp_path, activity_type="running")
|
||
|
|
|
||
|
|
assert got == 1
|
||
|
|
assert (tmp_path / "10.fit").exists()
|
||
|
|
assert not (tmp_path / "11.fit").exists()
|