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>
136 lines
5.5 KiB
Python
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
|