mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
Stands up Endurain behind the existing Caddy edge and imports RunnerUp activities into it. Endurain is a VIEWER: screen-locker's workout gate still reads the phone over adb and is untouched here, because a WebDAV drop folder is spoofable by anything that can write to ~/cloud and must not become a screen-unlock authority. Primary path is phone -> WebDAV (existing dufs) -> ~/cloud/RunnerUp/ -> importer -> Endurain, with an adb pull as fallback when a run has not been uploaded yet. A systemd user timer runs the importer every 15 min. Findings that shaped the implementation, each verified against the running system rather than assumed: - Endurain moved to Codeberg; the GitHub repo is a read-only mirror stale at v0.17.7 against v0.19.0. The image is pulled from Codeberg. - postgres:18 moved PGDATA to /var/lib/postgresql/18/docker. Upstream's compose example still mounts the pre-18 path, which fails as an unexplained "container is unhealthy"; the real cause is only visible in docker logs. - Endurain seeds a hardcoded admin/admin via an Alembic migration with no env override, and endurain.kuhy.duckdns.org already resolved publicly. Publishing is therefore a separate script that refuses to install the Caddy site while those credentials still work. - Endurain performs NO duplicate detection, so the importer owns dedupe on two axes: a content hash, and an activity key that collapses the .gpx/.tcx pair RunnerUp writes for a single run (different bytes, same run). - Ambiguous uploads (5xx/timeout) are never re-queued, since the activity may already have been committed; only definite 4xx rejections are staged for retry. The importer exits non-zero so the timer surfaces the problem. - RunnerUp has no background sync at all, so every upload is a manual tap. - Its WebDAV synchronizer needs HTTPS with a real cert (Android blocks cleartext at targetSdk 36; self-signed is broken upstream) and a URL with no trailing slash, and cannot MKCOL against dufs - so the inbox must pre-exist. The phone gets a dufs credential scoped to /RunnerUp, verified unable to read Keepass or gdrive_backups. Verified: 52 tests at 100% branch coverage; the timer imports 4 real runs and re-runs create zero duplicates; https://endurain.kuhy.duckdns.org serves 200 with a valid cert and the other edge sites are unaffected; screen-locker's suite still passes 1090 tests at 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
171 lines
5.2 KiB
Python
171 lines
5.2 KiB
Python
"""Inbox scanning and the adb fallback.
|
|
|
|
The fallback is read-only with respect to the phone: screen-locker reads the
|
|
same directory as its screen-unlock gate, so nothing here may delete or
|
|
rewrite those files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from python_pkg.endurain_import import sources
|
|
|
|
|
|
def test_inbox_lists_only_activity_files(tmp_path: Path) -> None:
|
|
(tmp_path / "a.tcx").write_text("x")
|
|
(tmp_path / "b.gpx").write_text("x")
|
|
(tmp_path / "c.fit").write_text("x")
|
|
(tmp_path / "notes.txt").write_text("x")
|
|
names = {p.name for p in sources.inbox_files(tmp_path)}
|
|
assert names == {"a.tcx", "b.gpx", "c.fit"}
|
|
|
|
|
|
def test_inbox_skips_processed_subdir(tmp_path: Path) -> None:
|
|
(tmp_path / "processed").mkdir()
|
|
(tmp_path / "processed" / "old.tcx").write_text("x")
|
|
assert sources.inbox_files(tmp_path) == []
|
|
|
|
|
|
def test_missing_inbox_is_not_an_error(tmp_path: Path) -> None:
|
|
assert sources.inbox_files(tmp_path / "nope") == []
|
|
|
|
|
|
def test_inbox_is_ordered_oldest_first(tmp_path: Path) -> None:
|
|
first = tmp_path / "first.tcx"
|
|
second = tmp_path / "second.tcx"
|
|
first.write_text("x")
|
|
second.write_text("x")
|
|
import os
|
|
|
|
os.utime(first, (1000, 1000))
|
|
os.utime(second, (2000, 2000))
|
|
assert [p.name for p in sources.inbox_files(tmp_path)] == [
|
|
"first.tcx",
|
|
"second.tcx",
|
|
]
|
|
|
|
|
|
def test_phone_attached_parses_adb_devices(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
sources,
|
|
"_adb",
|
|
lambda _a: (True, "List of devices attached\nABC123\tdevice\n"),
|
|
)
|
|
assert sources.phone_attached()
|
|
|
|
|
|
def test_phone_unauthorised_is_not_attached(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
sources,
|
|
"_adb",
|
|
lambda _a: (True, "List of devices attached\nABC123\tunauthorized\n"),
|
|
)
|
|
assert not sources.phone_attached()
|
|
|
|
|
|
def test_pull_skips_when_no_device(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "phone_attached", lambda: False)
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_pull_ignores_already_present_files(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "phone_attached", lambda: True)
|
|
monkeypatch.setattr(sources, "_adb", lambda _a: (True, "RunnerUp_ts_Running.tcx\n"))
|
|
(tmp_path / "RunnerUp_ts_Running.tcx").write_text("already here")
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_failed_pull_leaves_no_partial_file(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A half-transferred file must never look like a complete activity."""
|
|
monkeypatch.setattr(sources, "phone_attached", lambda: True)
|
|
|
|
def _adb(args: list[str]) -> tuple[bool, str]:
|
|
if args[0] == "shell":
|
|
return True, "RunnerUp_ts_Running.tcx\n"
|
|
return False, "pull failed"
|
|
|
|
monkeypatch.setattr(sources, "_adb", _adb)
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_adb_handles_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A machine with no adb reports 'no device', it does not crash the run."""
|
|
|
|
def _boom(*_a: object, **_k: object) -> None:
|
|
message = "adb not found"
|
|
raise OSError(message)
|
|
|
|
monkeypatch.setattr(subprocess, "run", _boom)
|
|
ok, out = sources._adb(["devices"])
|
|
assert not ok
|
|
assert "adb not found" in out
|
|
|
|
|
|
def test_adb_handles_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _slow(*_a: object, **_k: object) -> None:
|
|
raise subprocess.TimeoutExpired(cmd="adb", timeout=1)
|
|
|
|
monkeypatch.setattr(subprocess, "run", _slow)
|
|
ok, _ = sources._adb(["devices"])
|
|
assert not ok
|
|
|
|
|
|
def test_adb_returns_output_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _Proc:
|
|
returncode = 0
|
|
stdout = "out"
|
|
stderr = ""
|
|
|
|
monkeypatch.setattr(subprocess, "run", lambda *_a, **_k: _Proc())
|
|
ok, out = sources._adb(["devices"])
|
|
assert ok
|
|
assert out == "out"
|
|
|
|
|
|
def test_phone_attached_false_when_adb_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "_adb", lambda _a: (False, "boom"))
|
|
assert not sources.phone_attached()
|
|
|
|
|
|
def test_pull_reports_unlistable_directory(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "phone_attached", lambda: True)
|
|
monkeypatch.setattr(sources, "_adb", lambda _a: (False, "no such dir"))
|
|
assert sources.pull_from_phone(tmp_path) == []
|
|
|
|
|
|
def test_successful_pull_moves_into_place(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setattr(sources, "phone_attached", lambda: True)
|
|
|
|
def _adb(args: list[str]) -> tuple[bool, str]:
|
|
if args[0] == "shell":
|
|
return True, "RunnerUp_ts_Running.tcx\nnotes.txt\n"
|
|
Path(args[2]).write_text("pulled")
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(sources, "_adb", _adb)
|
|
pulled = sources.pull_from_phone(tmp_path)
|
|
assert [p.name for p in pulled] == ["RunnerUp_ts_Running.tcx"]
|
|
assert (tmp_path / "RunnerUp_ts_Running.tcx").read_text() == "pulled"
|
|
assert not list(tmp_path.glob(".*partial"))
|