Files
rpa_vision_v3/tests/unit/test_update_check_server.py
Dom 813b33b47e feat(update): DETTE-022 — noyau MAJ silencieuse client Léa (gated, swap en stub)
Logique PURE testée : parse_version semver (R3), decide_update code-only/full (R2),
should_update client (double garde anti-downgrade), download_update (staging only +
SHA256, downloader injectable). Endpoint GET /api/v1/agents/update/check gated
(RPA_AUTO_UPDATE_SERVER_ENABLED). Flags client+serveur OFF par défaut.
Swap fichiers / Lea.bat / restart = STUBS no-op réservés révision humaine.
34 tests TDD. refs DETTE-022

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:21:35 +02:00

136 lines
5.5 KiB
Python

"""TDD — DETTE-022 MAJ silencieuse v2 : logique PURE serveur de décision d'update.
Périmètre testé ICI = parties PURES, testables sans démarrer le serveur
(DETTE-013 : on N'IMPORTE PAS `api_stream` — on charge le module
`update_check.py` par chemin, comme test_agent_v1_log_shipper).
Couvre :
- R3 `parse_version()` : tuple d'entiers, "1.0.2" < "1.0.10", égalité,
"v1.2.3"/espaces tolérés, format invalide → fallback sans crash.
- R2 logique de décision PURE `decide_update()` : compare version courante
vs dernière dispo, choisit `update_type` (code-only/full), construit la
réponse `{update_available, latest_version, update_type, url}`.
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) est HORS périmètre.
"""
import importlib.util
from pathlib import Path
import pytest
_MOD_PATH = (
Path(__file__).resolve().parents[2]
/ "agent_v0" / "server_v1" / "update_check.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("rpa_update_check", _MOD_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def mod():
return _load_module()
# ---------------------------------------------------------------------------
# R3 — parse_version : tuple d'entiers (semver), pas comparaison lexicale
# ---------------------------------------------------------------------------
class TestParseVersion:
def test_parse_basique(self, mod):
assert mod.parse_version("1.0.2") == (1, 0, 2)
assert mod.parse_version("1.0.10") == (1, 0, 10)
def test_ordre_semver_pas_lexical(self, mod):
# Le bug classique : "1.0.2" < "1.0.10" est FAUX en lexicographique.
assert mod.parse_version("1.0.2") < mod.parse_version("1.0.10")
assert mod.parse_version("1.0.10") > mod.parse_version("1.0.2")
assert mod.parse_version("2.0.0") > mod.parse_version("1.99.99")
def test_egalite(self, mod):
assert mod.parse_version("1.0.1") == mod.parse_version("1.0.1")
def test_prefixe_v_et_espaces_toleres(self, mod):
assert mod.parse_version("v1.2.3") == mod.parse_version("1.2.3")
assert mod.parse_version(" 1.2.3 ") == (1, 2, 3)
assert mod.parse_version("V1.2.3") == (1, 2, 3)
def test_format_invalide_fallback_sans_crash(self, mod):
# Ne doit jamais lever — fallback (0,) (= la plus basse).
assert mod.parse_version("") == (0,)
assert mod.parse_version("abc") == (0,)
assert mod.parse_version(None) == (0,)
assert mod.parse_version("1.x.3") == (0,)
# Une version valide reste toujours > au fallback invalide.
assert mod.parse_version("0.0.1") > mod.parse_version("garbage")
def test_is_newer_helper(self, mod):
assert mod.is_newer("1.0.2", "1.0.1") is True
assert mod.is_newer("1.0.10", "1.0.2") is True
assert mod.is_newer("1.0.1", "1.0.1") is False
assert mod.is_newer("1.0.0", "1.0.1") is False
# ---------------------------------------------------------------------------
# R2 — decide_update : logique PURE de décision serveur
# ---------------------------------------------------------------------------
class TestDecideUpdate:
def test_pas_de_maj_si_a_jour(self, mod):
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.1")
assert resp["update_available"] is False
assert resp["latest_version"] == "1.0.1"
assert resp["update_type"] is None
assert resp["url"] is None
def test_pas_de_maj_si_client_plus_recent(self, mod):
# Client en avance (dev local) → jamais de downgrade.
resp = mod.decide_update(current_version="1.0.5", latest_version="1.0.2")
assert resp["update_available"] is False
def test_maj_disponible_code_only_par_defaut(self, mod):
resp = mod.decide_update(current_version="1.0.1", latest_version="1.0.2")
assert resp["update_available"] is True
assert resp["latest_version"] == "1.0.2"
# R2 : code-only = défaut (99% des cas, ~500 Ko).
assert resp["update_type"] == "code-only"
assert "1.0.2" in resp["url"]
assert "code-only" in resp["url"]
def test_maj_full_si_demande(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.1.0", update_type="full"
)
assert resp["update_available"] is True
assert resp["update_type"] == "full"
assert "full" in resp["url"]
def test_update_type_invalide_retombe_sur_code_only(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.0.2", update_type="banana"
)
assert resp["update_type"] == "code-only"
def test_ordre_semver_dans_decision(self, mod):
# 1.0.2 < 1.0.10 → MAJ dispo (pas de faux négatif lexical).
resp = mod.decide_update(current_version="1.0.2", latest_version="1.0.10")
assert resp["update_available"] is True
def test_url_inclut_machine_id_si_fourni(self, mod):
resp = mod.decide_update(
current_version="1.0.1", latest_version="1.0.2", machine_id="pc-7"
)
assert "pc-7" in resp["url"]
def test_versions_invalides_pas_de_crash_pas_de_maj(self, mod):
# latest illisible → on ne propose RIEN (prudence : pas de MAJ douteuse).
resp = mod.decide_update(current_version="1.0.1", latest_version="garbage")
assert resp["update_available"] is False
resp2 = mod.decide_update(current_version="", latest_version="")
assert resp2["update_available"] is False