mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
RunnerUp can now upload to Endurain directly as well as through the WebDAV inbox, and the two paths cannot see each other: the same run landed twice (activities 14 and 15, identical start time, distances differing only by tcx/gpx rounding). The ledger only knows what this importer handled, so it could not catch it. Add a third dedupe check that asks Endurain what it already holds, queried once per run. Endurain exposes no query-by-start-time endpoint, so a page of recent activities is fetched and compared client-side, with a +/-120s tolerance: the file for activity 15 embeds 21:51:04Z while the server stored 21:51:05Z, so exact equality misses the very duplicate that motivated this. A failed or unauthorised query returns None, never an empty list, so it can never be read as "nothing matches" and upload on the strength of it. Export filenames are parsed as LOCAL wall-clock time. RunnerUp_2026-08-14- 00-58-45_Running is 2026-08-13 22:58:45 UTC; reading it as UTC would move a late-evening run onto the wrong day and defeat the comparison. Also fix the adb fallback, which broke the moment a second device attached: phone_attached() returned true if ANY device was in the 'device' state, then every bare adb call failed with "more than one device/emulator" as a warning on a path nobody watches. The serial is now resolved once and threaded into every call as -s. Ambiguity is settled by configuration, never by guessing -- several devices and no ENDURAIN_ADB_SERIAL means the fallback is skipped. The remote check stays inert until the API key is granted activities:read; until then it falls back to the ledger, which is the previous behaviour. 85 tests, 100% branch coverage on all four modules, no suppressions. The oversized test modules are split by concern to stay under the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
140 lines
4.5 KiB
Python
140 lines
4.5 KiB
Python
"""Asking Endurain what it already holds.
|
|
|
|
A failed lookup must return None, never an empty list: read as "nothing
|
|
matches" it would upload a duplicate on every transport hiccup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from python_pkg.endurain_import import upload
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, status: int, payload: object, *, bad_json: bool = False) -> None:
|
|
self.status_code = status
|
|
self._payload = payload
|
|
self._bad_json = bad_json
|
|
|
|
def json(self) -> object:
|
|
if self._bad_json:
|
|
message = "not json"
|
|
raise ValueError(message)
|
|
return self._payload
|
|
|
|
|
|
def _client_with_get(
|
|
monkeypatch: pytest.MonkeyPatch, result: object
|
|
) -> upload.EndurainClient:
|
|
client = upload.EndurainClient("http://x", "k")
|
|
|
|
def _get(*_a: object, **_k: object) -> object:
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
return result
|
|
|
|
monkeypatch.setattr(client._session, "get", _get)
|
|
return client
|
|
|
|
|
|
def test_recent_start_times_parses_offsets(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client = _client_with_get(
|
|
monkeypatch,
|
|
_Resp(200, [{"start_time": "2026-08-22T23:51:05+02:00"}]),
|
|
)
|
|
times = client.recent_start_times(1)
|
|
assert times is not None
|
|
assert times[0] == datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_recent_start_times_treats_naive_as_utc(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
client = _client_with_get(
|
|
monkeypatch, _Resp(200, [{"start_time": "2026-08-22T21:51:05"}])
|
|
)
|
|
times = client.recent_start_times(1)
|
|
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
|
|
|
|
|
|
def test_recent_start_times_accepts_zulu(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client = _client_with_get(
|
|
monkeypatch, _Resp(200, [{"start_time": "2026-08-22T21:51:05Z"}])
|
|
)
|
|
times = client.recent_start_times(1)
|
|
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
|
|
|
|
|
|
def test_unparseable_entries_are_dropped_not_fatal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
client = _client_with_get(
|
|
monkeypatch,
|
|
_Resp(
|
|
200,
|
|
[
|
|
{"start_time": "nonsense"},
|
|
{"start_time": None},
|
|
{"start_time": ""},
|
|
{"no_start_time": 1},
|
|
"not-a-dict",
|
|
{"start_time": "2026-08-22T21:51:05Z"},
|
|
],
|
|
),
|
|
)
|
|
assert client.recent_start_times(1) == [
|
|
datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)
|
|
]
|
|
|
|
|
|
def test_auth_failure_is_unknown_not_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""401 must not read as 'no activities exist' -- that would duplicate."""
|
|
client = _client_with_get(monkeypatch, _Resp(401, None))
|
|
assert client.recent_start_times(1) is None
|
|
|
|
|
|
def test_transport_failure_is_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client = _client_with_get(monkeypatch, requests.ConnectionError("down"))
|
|
assert client.recent_start_times(1) is None
|
|
|
|
|
|
def test_non_json_body_is_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client = _client_with_get(monkeypatch, _Resp(200, None, bad_json=True))
|
|
assert client.recent_start_times(1) is None
|
|
|
|
|
|
def test_non_list_body_is_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
client = _client_with_get(monkeypatch, _Resp(200, {"detail": "nope"}))
|
|
assert client.recent_start_times(1) is None
|
|
|
|
|
|
def test_already_present_tolerates_recording_skew() -> None:
|
|
"""The file said 21:51:04Z; Endurain stored 21:51:05Z. Same run."""
|
|
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
|
|
known = [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
|
|
assert upload.already_present(start, known)
|
|
|
|
|
|
def test_already_present_rejects_a_different_run() -> None:
|
|
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
|
|
known = [datetime(2026, 8, 22, 19, 0, 0, tzinfo=timezone.utc)]
|
|
assert not upload.already_present(start, known)
|
|
|
|
|
|
def test_tolerance_boundary_is_inclusive() -> None:
|
|
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
|
|
inside = [datetime(2026, 8, 22, 21, 53, 4, tzinfo=timezone.utc)]
|
|
outside = [datetime(2026, 8, 22, 21, 53, 5, tzinfo=timezone.utc)]
|
|
assert upload.already_present(start, inside)
|
|
assert not upload.already_present(start, outside)
|
|
|
|
|
|
def test_already_present_against_nothing_is_false() -> None:
|
|
assert not upload.already_present(
|
|
datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc), []
|
|
)
|