erplibre/test/test_uninstall_verified.py

465 lines
18 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une désinstallation se MESURE, elle ne se suppose pas.
`odoo-bin --uninstall` ne cherche que l'état « installed ». Un module resté
en « to remove » d'une tentative précédente est ignoré en silence, et Odoo
sort en 0. Le pilote tenait ce 0 pour une réussite : c'est ainsi que
muk_web_theme a traversé quatre paliers de 12 → 18 en étant réputé retiré,
alors qu'il était « installed » de la 15 à la 18.
"""
import ast
import io
import os
[FIX] migration: un module fautif n'emporte plus tout le lot « --uninstall » prend une liste virgulée et Odoo annule la transaction entière au premier échec : soit tout part, soit rien. Mesuré sur une chaîne 12 → 18 — crm_phone échoue sur une colonne absente de res_users et fait tomber les 22 autres avec lui, dont huit modules maison sans code en 13. Ceux-là sont alors montés d'un palier « installed » sans rien pour les charger, et ne sont partis que trois paliers plus loin, par accident. Le lot part toujours d'abord — un démarrage d'Odoo par nom se paierait cher pour rien. Mais s'il échoue, on reprend un par un : ce qui peut partir part, et l'on nomme ce qui résiste. S'ajoute la liste 12 → 13, qui n'existait pas : sans elle l'étape « Uninstall module » n'avait rien à faire. --- EN --- « --uninstall » takes a comma list and Odoo rolls the whole transaction back on the first failure: all or nothing. Measured on a 12 → 18 chain — crm_phone fails on a column missing from res_users and drags the other 22 down with it, including eight in-house modules with no code in 13. Those rode a step up « installed » with nothing to load them, and only left three steps later, by accident. The batch still goes first — one Odoo start per name would cost dearly for nothing. But if it fails, we retry one by one: what can leave leaves, and we name what resists. Plus the 12 → 13 list, which did not exist: without it the « Uninstall module » step had nothing to do. Assisted-by: Claude Opus 5
2026-08-24 05:29:38 -04:00
import re
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
SOURCE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py")
class FauxPilote(TodoUpgrade):
"""Un pilote qui n'exécute rien, mais respecte le contrat de retour."""
def __init__(self, survivants=(), lisible=True):
self.survivants = list(survivants)
self.lisible = lisible
self.commandes = []
self.commentaires = []
self.dct_progression = {}
self.dct_module_per_version = {}
def todo_upgrade_execute(self, cmd, **kwargs):
self.commandes.append(cmd)
if kwargs.get("get_output"):
# Le vrai rend TROIS valeurs quand on demande la sortie, et un
# statut NON nul veut dire « raté ».
if not self.lisible:
return 1, cmd, None
return 0, cmd, list(self.survivants)
return 0, cmd
def write_config(self):
pass
def add_comment_progression(self, msg):
self.commentaires.append(msg)
def split_present_missing(self, lst):
return list(lst), []
class TestStillInstalled(unittest.TestCase):
def test_it_names_what_survived(self):
pilote = FauxPilote(survivants=["muk_web_theme"])
self.assertEqual(
pilote.still_installed("db", ["muk_web_theme", "web_responsive"]),
["muk_web_theme"],
)
def test_an_unreadable_database_says_UNKNOWN_not_empty(self):
# Rendre [] serait affirmer « tout est parti » sans l'avoir lu :
# exactement le défaut qu'on corrige.
pilote = FauxPilote(lisible=False)
self.assertIsNone(pilote.still_installed("db", ["web_responsive"]))
def test_nothing_to_check_asks_the_database_nothing(self):
pilote = FauxPilote()
self.assertEqual(pilote.still_installed("db", []), [])
self.assertEqual(pilote.commandes, [])
def test_it_counts_to_remove_as_still_there(self):
# « to remove » n'est pas « uninstalled » : c'est justement l'état
# que --uninstall refuse de traiter, donc celui qu'il faut voir.
pilote = FauxPilote()
pilote.still_installed("db", ["web_responsive"])
self.assertIn("state <> 'uninstalled'", pilote.commandes[0])
def test_the_module_names_are_quoted_for_sql(self):
pilote = FauxPilote()
pilote.still_installed("db", ["web_responsive"])
self.assertIn("'web_responsive'", pilote.commandes[0])
class TestTheBookkeepingTellsTheTruth(unittest.TestCase):
def pilote(self, survivants):
pilote = FauxPilote(survivants=survivants)
pilote.dct_module_per_version = {
17: ["web_responsive", "muk_web_theme"]
}
return pilote
def test_a_module_left_in_place_stays_counted_as_installed(self):
pilote = self.pilote(["web_responsive"])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertIn("web_responsive", pilote.dct_module_per_version[17])
def test_a_module_really_gone_is_dropped(self):
pilote = self.pilote([])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertNotIn("web_responsive", pilote.dct_module_per_version[17])
# …et sans emporter le voisin au passage.
self.assertIn("muk_web_theme", pilote.dct_module_per_version[17])
def test_the_survivor_is_recorded_where_someone_will_read_it(self):
pilote = self.pilote(["web_responsive"])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
trace = " ".join(pilote.commentaires)
self.assertIn("still installed", trace)
self.assertIn("web_responsive", trace)
def test_an_unreadable_database_does_not_crash_the_migration(self):
# « je ne sais pas » revient en None : le traiter comme une liste
# ferait tomber la migration sur un TypeError, six heures après le
# départ, pour un renseignement qui n'était que confortable.
pilote = FauxPilote(lisible=False)
pilote.dct_module_per_version = {17: ["web_responsive"]}
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertEqual(pilote.dct_module_per_version[17], [])
def test_a_silent_success_leaves_no_alarm(self):
pilote = self.pilote([])
pilote.uninstall_from_database(["web_responsive"], "db", 17)
self.assertEqual(
[c for c in pilote.commentaires if "still installed" in c], []
)
class TestNoStepWritesAnotherStepsFlag(unittest.TestCase):
"""Chaque drapeau `state_4_*` ne doit porter QUE sa propre liste.
Trois étapes rangeaient leurs drapeaux sous
`state_4_module_migrate_odoo_lst`. Sans effet dans la course en cours —
la locale est lue une fois, au début — mais à la REPRISE cette clé est
relue comme « OpenUpgrade est passé », et la migration du palier est
sautée. Un test structurel se justifie ici : conduire une reprise
complète coûterait des heures, et la faute est visible dans l'écriture.
"""
def assignations(self):
with io.open(SOURCE, encoding="utf-8") as handle:
arbre = ast.parse(handle.read())
vues = {}
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Assign):
continue
for cible in noeud.targets:
if not (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Attribute)
and cible.value.attr == "dct_progression"
and isinstance(cible.slice, ast.Constant)
and str(cible.slice.value).startswith("state_4_")
):
continue
if isinstance(noeud.value, ast.Name):
vues.setdefault(cible.slice.value, set()).add(
noeud.value.id
)
return vues
def test_the_scan_actually_finds_something(self):
# Sans cette borne, le test suivant passerait sur un dictionnaire
# vide le jour où la forme de l'écriture change.
self.assertGreater(len(self.assignations()), 2)
def test_each_flag_is_written_from_one_list_only(self):
for cle, noms in sorted(self.assignations().items()):
self.assertEqual(
len(noms), 1, f"{cle} écrit depuis {sorted(noms)}"
)
def test_the_migrate_flag_comes_from_the_migrate_list(self):
self.assertEqual(
self.assignations().get("state_4_module_migrate_odoo_lst"),
{"lst_module_migrate_odoo"},
)
class TestAFailedOpenUpgradeStaysUnrecorded(unittest.TestCase):
"""Un OpenUpgrade raté ne doit pas passer pour fait.
`lst_upgrade_odoo` n'est pas une copie : `dct_progression.get()` rend
l'objet stocké. L'affecter avant l'exécution le faisait persister au
premier `write_config()` venu — celui du chemin d'échec compris, qui
remet pourtant le drapeau de clonage à zéro pour forcer un nouvel
essai. La reprise sautait alors OpenUpgrade et laissait une base 17
tourner sous le code 18. Mesuré sur test_neutralize_upgrade_18 :
base = 17.0.1.3, et sa commande de migration déjà consignée.
Conduire `execute_odoo_upgrade` en vrai demanderait une migration
complète ; la faute est un ORDRE dans le source, et c'est l'ordre
qu'on mesure.
"""
def arbre(self):
with io.open(SOURCE, encoding="utf-8") as handle:
return ast.parse(handle.read())
def lignes_affectation(self):
lignes = []
for noeud in ast.walk(self.arbre()):
if not isinstance(noeud, ast.Assign):
continue
for cible in noeud.targets:
if (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Name)
and cible.value.id == "lst_upgrade_odoo"
):
lignes.append(noeud.lineno)
return lignes
@staticmethod
def _remet_le_clone_a_zero(noeud):
"""Ce bloc renonce-t-il en redemandant un clonage neuf ?
Le repère est l'affectation `lst_clone_odoo[index] = False` : c'est
elle qui distingue « je renonce, refais le clone » de l'étape de
clonage elle-même, qui écrit `= True` et vit ailleurs. Chercher les
seuls NOMS attrapait les deux, et l'ancre tombait 700 lignes trop
haut — le test passait alors sur n'importe quel ordre.
"""
for petit in ast.walk(noeud):
if not isinstance(petit, ast.Assign):
continue
if not (
isinstance(petit.value, ast.Constant)
and petit.value.value is False
):
continue
for cible in petit.targets:
if (
isinstance(cible, ast.Subscript)
and isinstance(cible.value, ast.Name)
and cible.value.id == "lst_clone_odoo"
):
return True
return False
def ligne_abandon(self):
"""Le `return` qui renonce après un OpenUpgrade raté."""
lignes = [
max(n.lineno for n in ast.walk(noeud) if isinstance(n, ast.Return))
for noeud in ast.walk(self.arbre())
if isinstance(noeud, ast.If)
and self._remet_le_clone_a_zero(noeud)
and any(isinstance(n, ast.Return) for n in ast.walk(noeud))
]
return min(lignes) if lignes else None
def test_both_anchors_are_found(self):
# Sans cette borne, les tests suivants passeraient à vide le jour
# où l'une des deux formes change.
self.assertTrue(self.lignes_affectation())
self.assertIsNotNone(self.ligne_abandon())
def test_the_step_is_recorded_only_after_the_failure_path_gave_up(self):
abandon = self.ligne_abandon()
for ligne in self.lignes_affectation():
self.assertGreater(
ligne,
abandon,
"lst_upgrade_odoo est marqué fait avant que l'échec ait"
" pu renoncer : la reprise sautera OpenUpgrade",
)
def test_it_is_recorded_exactly_once(self):
# Deux affectations, et l'une repasserait devant l'échec.
self.assertEqual(len(self.lignes_affectation()), 1)
class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase):
"""Rebâtir le clone annule tout ce qu'on lui avait fait.
Quand OpenUpgrade échoue, le pilote remet le drapeau de clonage à zéro
pour que la base intermédiaire soit refaite depuis la version
précédente. Mais les drapeaux des étapes qui avaient préparé CE
clone — le SQL de pré-migration, les désinstallations, les
installations — restaient debout. La base neuve repartait donc sans
sa préparation, et OpenUpgrade retombait sur le problème même que le
SQL existe pour écarter.
"""
PAR_CLONE = (
"lst_fix_migration_odoo",
"lst_module_uninstall_module",
"lst_module_install_module",
)
def bloc_abandon(self):
with io.open(SOURCE, encoding="utf-8") as handle:
arbre = ast.parse(handle.read())
for noeud in ast.walk(arbre):
if isinstance(
noeud, ast.If
) and TestAFailedOpenUpgradeStaysUnrecorded._remet_le_clone_a_zero(
noeud
):
return noeud
return None
def remis_a_zero(self):
"""Les listes que ce bloc remet à leur valeur vide."""
noms = set()
for petit in ast.walk(self.bloc_abandon()):
if not isinstance(petit, ast.Assign):
continue
if not (
isinstance(petit.value, ast.Constant)
and petit.value.value is False
) and not (
isinstance(petit.value, ast.List) and not petit.value.elts
):
continue
for cible in petit.targets:
if isinstance(cible, ast.Subscript) and isinstance(
cible.value, ast.Name
):
noms.add(cible.value.id)
return noms
def test_the_failure_block_is_found(self):
self.assertIsNotNone(self.bloc_abandon())
def test_every_per_clone_flag_is_reset(self):
remis = self.remis_a_zero()
for nom in self.PAR_CLONE:
self.assertIn(nom, remis, f"{nom} survit à son clone")
def test_each_reset_is_persisted(self):
# Remettre la liste à zéro sans l'écrire ne survit pas au
# processus : c'est la reprise qui relit le fichier.
corps = ast.dump(self.bloc_abandon())
for cle in (
"state_4_fix_migration_odoo_lst",
"state_4_uninstall_module",
"state_4_install_module",
):
self.assertIn(cle, corps)
self.assertIn("write_config", corps)
[FIX] migration: un module fautif n'emporte plus tout le lot « --uninstall » prend une liste virgulée et Odoo annule la transaction entière au premier échec : soit tout part, soit rien. Mesuré sur une chaîne 12 → 18 — crm_phone échoue sur une colonne absente de res_users et fait tomber les 22 autres avec lui, dont huit modules maison sans code en 13. Ceux-là sont alors montés d'un palier « installed » sans rien pour les charger, et ne sont partis que trois paliers plus loin, par accident. Le lot part toujours d'abord — un démarrage d'Odoo par nom se paierait cher pour rien. Mais s'il échoue, on reprend un par un : ce qui peut partir part, et l'on nomme ce qui résiste. S'ajoute la liste 12 → 13, qui n'existait pas : sans elle l'étape « Uninstall module » n'avait rien à faire. --- EN --- « --uninstall » takes a comma list and Odoo rolls the whole transaction back on the first failure: all or nothing. Measured on a 12 → 18 chain — crm_phone fails on a column missing from res_users and drags the other 22 down with it, including eight in-house modules with no code in 13. Those rode a step up « installed » with nothing to load them, and only left three steps later, by accident. The batch still goes first — one Odoo start per name would cost dearly for nothing. But if it fails, we retry one by one: what can leave leaves, and we name what resists. Plus the 12 → 13 list, which did not exist: without it the « Uninstall module » step had nothing to do. Assisted-by: Claude Opus 5
2026-08-24 05:29:38 -04:00
class FauxPiloteParNom(FauxPilote):
"""Un faux qui reproduit le VRAI mécanisme du lot.
« --uninstall » prend une liste virgulée, et Odoo annule la
transaction entière au premier échec : soit tout part, soit rien.
C'est cela qu'il faut simuler — un faux où chaque nom part
indépendamment ne montrerait jamais le défaut.
"""
def __init__(self, fautifs=()):
super().__init__()
self.fautifs = set(fautifs)
self.partis = set()
self.tentes = []
def todo_upgrade_execute(self, cmd, **kwargs):
self.commandes.append(cmd)
if kwargs.get("get_output"):
# `still_installed` : ce qu'on demande et qui n'est pas parti.
#
# Les noms se lisent dans la clause `IN (…)` SEULE : une
# capture large ramassait `'uninstalled'` du `state <>` et
# le faux tentait alors de désinstaller un module de ce nom.
dedans = re.search(r"name IN \(([^)]*)\)", cmd)
demandes = re.findall(
r"'([^']+)'", dedans.group(1) if dedans else ""
)
return 0, cmd, [n for n in demandes if n not in self.partis]
if "uninstall_addons.sh" in cmd:
noms = cmd.rsplit(" ", 1)[-1].split(",")
self.tentes.append(list(noms))
if not any(n in self.fautifs for n in noms):
self.partis.update(noms)
return 0, cmd
class TestOneBadNameNoLongerProtectsTheOthers(unittest.TestCase):
"""Un seul module fautif emportait tout le lot.
« --uninstall » prend une liste et Odoo annule la transaction
entière au premier échec. Mesuré sur une chaîne 12 → 18 :
`crm_phone` échoue sur une colonne absente de res_users et fait
tomber les 22 autres — dont huit modules sans code en 13, qui sont
alors montés d'un palier « installed » sans rien pour les charger.
"""
def pilote(self, fautifs, tous):
p = FauxPiloteParNom(fautifs)
p.dct_module_per_version = {12: list(tous)}
return p
def test_the_healthy_ones_leave_even_when_one_resists(self):
tous = ["bon_a", "crm_phone", "bon_b"]
p = self.pilote(["crm_phone"], tous)
p.uninstall_from_database(tous, "db", 12)
# Le lot d'abord, puis chacun seul…
self.assertEqual(tous, p.tentes[0])
self.assertEqual(
sorted(n for lot in p.tentes[1:] for n in lot),
["bon_a", "bon_b", "crm_phone"],
)
# …et la comptabilité ne garde que le fautif.
self.assertEqual(["crm_phone"], p.dct_module_per_version[12])
def test_the_one_that_resists_is_named(self):
tous = ["bon_a", "crm_phone"]
p = self.pilote(["crm_phone"], tous)
p.uninstall_from_database(tous, "db", 12)
trace = " ".join(p.commentaires)
self.assertIn("crm_phone", trace)
self.assertNotIn("bon_a", trace)
def test_a_lone_module_is_not_tried_twice(self):
# La reprise coûte un démarrage d'Odoo par nom : on ne la paie
# pas pour rejouer exactement la même commande.
p = self.pilote(["seul"], ["seul"])
p.uninstall_from_database(["seul"], "db", 12)
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
self.assertEqual(1, len(lancements), lancements)
def test_a_successful_batch_never_retries(self):
tous = ["bon_a", "bon_b"]
p = self.pilote([], tous)
p.uninstall_from_database(tous, "db", 12)
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
self.assertEqual(1, len(lancements), lancements)
def test_the_retry_does_not_open_the_error_menu(self):
# Un module qui refuse de partir est une trouvaille, pas une
# panne du pilote : s'arrêter là ferait échouer la migration
# entière pour un module que l'on nomme déjà.
import ast
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
arbre = ast.parse(handle.read())
for noeud in ast.walk(arbre):
if (
isinstance(noeud, ast.FunctionDef)
and noeud.name == "uninstall_one_by_one"
):
corps = ast.dump(ast.Module(body=noeud.body, type_ignores=[]))
self.assertIn("wait_at_error", corps)
self.assertIn("value=False", corps)
return
self.fail("uninstall_one_by_one introuvable")
if __name__ == "__main__":
unittest.main()