Le journal d'étape notait la commande et son code de retour, jamais ce qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un échec. Un tube aurait capturé et changé le programme — smoke_public_url appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière un tube il cesse en silence d'offrir la réparation des vues COW. Un pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la réponse tapée lui parvient, le code de retour survit. Neuf exécutions y passent, dont check_hidden_models, qui tournait sans verdict retenu ; deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait. --- EN --- The step log recorded the command and its exit code, never what it wrote: the analysis screen could show nothing of a failure. A pipe would have captured and changed the program — smoke_public_url calls can_ask(), which requires stdin AND stdout to be terminals, and behind a pipe it silently stops offering the COW view repair. A pty settles it: the child sees a real terminal, a typed answer reaches it, the exit code survives. Nine runs go through it, including check_hidden_models, which ran with no verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen app would lay out on nothing. Assisted-by: Claude Opus 5 (cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
971 lines
37 KiB
Python
Executable file
971 lines
37 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge.
|
|
|
|
Les huit purges ne sont pas indépendantes : purger un modèle libère les
|
|
colonnes qui le référençaient, purger une table libère les données qui la
|
|
visaient. Une seule passe ne suffit jamais, et l'ordre compte.
|
|
|
|
Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module
|
|
refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque
|
|
entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas
|
|
réparé, la suivante le peut, une fois les voisines parties.
|
|
|
|
Ces tests exécutent le script réellement poussé dans le shell, sur un `env`
|
|
simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et
|
|
l'arrêt de la boucle sans lancer Odoo sur une vraie base.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
|
|
|
import database_cleanup as cleanup # noqa: E402
|
|
|
|
|
|
class FakeLine:
|
|
def __init__(self, name, fails=0, journal=None):
|
|
self.name = name
|
|
self.id = abs(hash(name)) % 10000
|
|
self.fails = fails # nombre de refus avant de céder
|
|
self.journal = journal if journal is not None else []
|
|
self.purged = False
|
|
|
|
def purge(self):
|
|
# Comme le vrai : une ligne déjà purgée ne l'est pas deux fois.
|
|
# `purge()` d'OCA filtre sur `not x.purged`, et sans cela le repli
|
|
# ligne à ligne recompterait ce que le lot avait déjà fait.
|
|
if self.purged:
|
|
return True
|
|
self.journal.append(("purge", self.name))
|
|
if self.fails > 0:
|
|
self.fails -= 1
|
|
raise RuntimeError(f"refus sur {self.name}")
|
|
self.purged = True
|
|
return True
|
|
|
|
|
|
class FakeRecordset(list):
|
|
"""Un `purge_line_ids` qui se purge EN LOT, comme le vrai.
|
|
|
|
C'est TOUT l'enjeu du correctif : `purge()` d'un module appelle
|
|
button_immediate_uninstall(), qui recharge le registre entier. Un appel
|
|
par ligne en faisait un rechargement par module — mesuré, dix secondes
|
|
chacun. Un seul appel sur le lot, c'est un seul rechargement.
|
|
"""
|
|
|
|
def __init__(self, lines, journal=None):
|
|
super().__init__(lines)
|
|
self.journal = journal if journal is not None else []
|
|
|
|
def purge(self):
|
|
self.journal.append(("purge_batch", len(self)))
|
|
for line in self:
|
|
line.purge()
|
|
return True
|
|
|
|
|
|
class FakeWizard:
|
|
def __init__(self, lines, journal=None):
|
|
self.purge_line_ids = FakeRecordset(lines, journal)
|
|
|
|
|
|
class FakeModel:
|
|
def __init__(self, lines, raise_on_create=None):
|
|
self._lines = lines
|
|
self._raise = raise_on_create
|
|
|
|
def create(self, values):
|
|
if self._raise:
|
|
raise RuntimeError(self._raise)
|
|
# Les lignes déjà purgées ne reviennent pas : find() les recalcule.
|
|
reste = [ln for ln in self._lines if ln.fails >= 0 and not ln.purged]
|
|
journal = reste[0].journal if reste else None
|
|
return FakeWizard(reste, journal)
|
|
|
|
|
|
class FakeCursor:
|
|
"""Volontairement SANS `savepoint` : c'est l'invariant du correctif.
|
|
|
|
Le module OCA valide de lui-même — `purge_modules.find()` purge (ligne
|
|
91) et `purge_columns.purge()` appelle `cr.commit()` (ligne 57). Un
|
|
COMMIT détruit tous les points de reprise. En reprendre un ici ferait
|
|
revivre le défaut sans que rien ne le dise ; l'absence de la méthode
|
|
le fait échouer tout de suite.
|
|
"""
|
|
|
|
def __init__(self, journal):
|
|
self.journal = journal
|
|
|
|
def commit(self):
|
|
self.journal.append(("commit", None))
|
|
|
|
def rollback(self):
|
|
self.journal.append(("rollback", None))
|
|
|
|
|
|
class AbortingCursor(FakeCursor):
|
|
"""Ce que PostgreSQL fait VRAIMENT après une erreur.
|
|
|
|
La transaction reste avortée et refuse tout ordre jusqu'au rollback.
|
|
C'est ce qui changeait une panne sur « modules » en sept catégories
|
|
mortes, toutes sur « current transaction is aborted ».
|
|
"""
|
|
|
|
def __init__(self, journal):
|
|
super().__init__(journal)
|
|
self.aborted = False
|
|
|
|
def commit(self):
|
|
if self.aborted:
|
|
raise RuntimeError(
|
|
"current transaction is aborted, commands ignored"
|
|
" until end of transaction block"
|
|
)
|
|
super().commit()
|
|
|
|
def rollback(self):
|
|
super().rollback()
|
|
self.aborted = False
|
|
|
|
|
|
class CommittingLine(FakeLine):
|
|
"""Une purge qui valide toute seule, comme `purge_columns` le fait.
|
|
|
|
Le COMMIT emporte le point de reprise ; l'ordre suivant meurt sur
|
|
« savepoint ... does not exist » et laisse la transaction avortée.
|
|
"""
|
|
|
|
def __init__(self, name, cursor, journal=None):
|
|
super().__init__(name, journal=journal)
|
|
self.cursor = cursor
|
|
|
|
def purge(self):
|
|
self.journal.append(("purge", self.name))
|
|
self.cursor.commit()
|
|
self.cursor.aborted = True
|
|
raise RuntimeError('savepoint "10eb69719a9211f1" does not exist')
|
|
|
|
|
|
class FakeEnv(dict):
|
|
def __init__(self, mapping, journal, cursor=None):
|
|
super().__init__(mapping)
|
|
self.cr = cursor if cursor is not None else FakeCursor(journal)
|
|
|
|
|
|
class UserError(Exception):
|
|
"""Celle que le script poussé importera : on fournit odoo.exceptions.
|
|
|
|
Refaire une classe de son côté ne servirait à rien — « except » compare
|
|
des identités, pas des noms, et le test passerait à côté.
|
|
"""
|
|
|
|
|
|
def install_fake_odoo_exceptions(case):
|
|
"""Rendre `from odoo.exceptions import UserError` possible ici."""
|
|
import types
|
|
|
|
odoo = sys.modules.get("odoo") or types.ModuleType("odoo")
|
|
exceptions = types.ModuleType("odoo.exceptions")
|
|
exceptions.UserError = UserError
|
|
avant_odoo = sys.modules.get("odoo")
|
|
avant_exc = sys.modules.get("odoo.exceptions")
|
|
sys.modules["odoo"] = odoo
|
|
sys.modules["odoo.exceptions"] = exceptions
|
|
|
|
def remettre():
|
|
for nom, valeur in (
|
|
("odoo", avant_odoo),
|
|
("odoo.exceptions", avant_exc),
|
|
):
|
|
if valeur is None:
|
|
sys.modules.pop(nom, None)
|
|
else:
|
|
sys.modules[nom] = valeur
|
|
|
|
case.addCleanup(remettre)
|
|
|
|
|
|
def run_script(models, max_round=10, dry_run=False, cursor=None):
|
|
"""Exécuter le script réellement poussé, et rendre (rapport, journal)."""
|
|
import json
|
|
|
|
journal = cursor.journal if cursor is not None else []
|
|
env = FakeEnv(models, journal, cursor=cursor)
|
|
namespace = {"env": env}
|
|
exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102
|
|
# Le script imprime le rapport entre deux sentinelles ; ici on le relit
|
|
# dans son espace de noms, ce qui teste la MÊME structure.
|
|
return json.loads(json.dumps(namespace["report"])), journal
|
|
|
|
|
|
class TestTheOrder(unittest.TestCase):
|
|
def test_the_requested_order_is_kept(self):
|
|
# Purger un modèle libère des colonnes : l'inverse ne marcherait pas.
|
|
self.assertEqual(
|
|
[kind for kind, _model in cleanup.ORDER],
|
|
[
|
|
"models",
|
|
"modules",
|
|
"columns",
|
|
"tables",
|
|
"data",
|
|
"menus",
|
|
"indexes",
|
|
"properties",
|
|
],
|
|
)
|
|
|
|
def test_indexes_come_after_the_purges(self):
|
|
kinds = [kind for kind, _ in cleanup.ORDER]
|
|
self.assertGreater(kinds.index("indexes"), kinds.index("tables"))
|
|
|
|
def test_a_pass_visits_the_kinds_in_that_order(self):
|
|
models = {
|
|
model: FakeModel([FakeLine(f"{kind}-1")])
|
|
for kind, model in cleanup.ORDER
|
|
}
|
|
report, _journal = run_script(models, max_round=1)
|
|
self.assertEqual(
|
|
[entry["kind"] for entry in report["rounds"][0]],
|
|
[kind for kind, _ in cleanup.ORDER],
|
|
)
|
|
|
|
|
|
class TestOneEntryCannotSinkThePass(unittest.TestCase):
|
|
def test_a_healthy_category_is_purged_in_ONE_call(self):
|
|
# LE point du correctif. `purge()` d'un module appelle
|
|
# button_immediate_uninstall(), qui recharge le registre ENTIER —
|
|
# 5984 modules à relire. En purgeant ligne par ligne j'en faisais
|
|
# un rechargement PAR MODULE : mesuré, dix secondes chacun,
|
|
# dix-sept minutes pour neuf modules, sans rien afficher.
|
|
journal = []
|
|
lines = [
|
|
FakeLine("a", journal=journal),
|
|
FakeLine("b", journal=journal),
|
|
FakeLine("c", journal=journal),
|
|
]
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
report, _got = run_script(models, max_round=1)
|
|
# `journal` est celui des LIGNES : c'est là qu'atterrit la purge.
|
|
self.assertEqual(journal.count(("purge_batch", 3)), 1)
|
|
self.assertEqual(report["rounds"][0][0]["purged"], 3)
|
|
|
|
def test_the_healthy_case_commits_once_for_the_batch(self):
|
|
journal = []
|
|
lines = [
|
|
FakeLine("a", journal=journal),
|
|
FakeLine("b", journal=journal),
|
|
]
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
_report, got = run_script(models, max_round=1)
|
|
# Une validation après la création — `find()` peut avoir purgé de
|
|
# lui-même — et une pour le lot. Pas une par ligne.
|
|
self.assertEqual(got.count(("commit", None)), 2)
|
|
|
|
def test_a_refusal_gives_up_only_its_own(self):
|
|
journal = []
|
|
lines = [
|
|
FakeLine("ok1", journal=journal),
|
|
FakeLine("bad", fails=99, journal=journal),
|
|
FakeLine("ok2", journal=journal),
|
|
]
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
report, got = run_script(models, max_round=1)
|
|
entry = report["rounds"][0][0]
|
|
self.assertEqual(entry["purged"], 2)
|
|
self.assertEqual([name for name, _msg in entry["errors"]], ["bad"])
|
|
# Le lot a été tenté, a échoué, et SEULEMENT alors on isole. Le
|
|
# coût du ligne-à-ligne n'est payé que là où il sert.
|
|
self.assertEqual(journal.count(("purge_batch", 3)), 1)
|
|
self.assertGreaterEqual(got.count(("rollback", None)), 1)
|
|
|
|
def test_the_isolation_only_happens_after_a_refusal(self):
|
|
# Une catégorie saine ne doit JAMAIS passer par le repli : c'est
|
|
# lui qui coûtait dix secondes par module.
|
|
journal = []
|
|
models = {
|
|
cleanup.ORDER[0][1]: FakeModel(
|
|
[
|
|
FakeLine("a", journal=journal),
|
|
FakeLine("b", journal=journal),
|
|
]
|
|
)
|
|
}
|
|
_report, got = run_script(models, max_round=1)
|
|
self.assertEqual(got.count(("rollback", None)), 0)
|
|
|
|
def test_the_script_never_takes_a_savepoint(self):
|
|
# L'invariant du correctif, dit une fois pour toutes : le module OCA
|
|
# valide de lui-même, et un COMMIT détruit le point de reprise
|
|
# qu'on aurait pris. Le reprendre serait revenir au défaut.
|
|
self.assertNotIn("env.cr.savepoint", cleanup.build_script(1, False))
|
|
|
|
def test_a_wizard_that_cannot_even_be_created_is_recorded(self):
|
|
models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")}
|
|
report, _got = run_script(models, max_round=1)
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
self.assertIn("boom", report["failed"][0][2])
|
|
|
|
|
|
class TestTheReportSurvivesAnything(unittest.TestCase):
|
|
"""Sans rapport, on ne sait même pas si la base a été touchée.
|
|
|
|
Vécu : `create({})` échouait, l'erreur était notée mais la transaction
|
|
restait AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout
|
|
garde, et le script entier s'arrêtait — aucun rapport, juste une trace.
|
|
"""
|
|
|
|
def test_reading_the_names_is_inside_the_guard(self):
|
|
# C'est la lecture des noms qui déclenche la requête, pas la
|
|
# création : la laisser hors du `try` était le défaut — elle mourait
|
|
# sur une transaction déjà avortée, sans rien pour la rattraper.
|
|
source = cleanup.build_script(1, False)
|
|
creation = source.index("wizard = env[model].create({})")
|
|
garde = source.rindex("try:", 0, creation)
|
|
noms = source.index("line.name or str(line.id)")
|
|
rattrapage = source.index("except UserError:", noms)
|
|
self.assertLess(garde, creation)
|
|
self.assertLess(creation, noms)
|
|
self.assertLess(noms, rattrapage)
|
|
|
|
def test_a_failure_on_create_does_not_kill_the_run(self):
|
|
models = {
|
|
cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom"),
|
|
cleanup.ORDER[2][1]: FakeModel([FakeLine("colonne")]),
|
|
}
|
|
report, _got = run_script(models, max_round=1)
|
|
# La catégorie suivante a bien travaillé malgré l'échec de la
|
|
# première.
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
|
|
def test_an_unexpected_failure_still_yields_a_report(self):
|
|
class Explosive(dict):
|
|
def __init__(self, journal):
|
|
super().__init__()
|
|
self.cr = FakeCursor(journal)
|
|
|
|
def __contains__(self, key):
|
|
raise RuntimeError("registre en miettes")
|
|
|
|
import json as _json
|
|
|
|
journal = []
|
|
namespace = {"env": Explosive(journal)}
|
|
exec(cleanup.build_script(1, False), namespace) # noqa: S102
|
|
report = _json.loads(_json.dumps(namespace["report"]))
|
|
self.assertEqual(report["failed"][0][:2], ["*", "fatal"])
|
|
self.assertIn("miettes", report["failed"][0][2])
|
|
|
|
|
|
class TestTheSilenceThatLookedLikeAHang(unittest.TestCase):
|
|
"""Dix-sept minutes sans une ligne, et l'on croit à une boucle infinie.
|
|
|
|
Vécu, sur test_neutralize_upgrade_16 : l'outil affichait « ⧖ Nettoyage
|
|
de … » puis PLUS RIEN. Le processus travaillait — zéro verrou en
|
|
attente, des requêtes qui changeaient à chaque instantané — mais un
|
|
travail qui avance et un blocage se ressemblent trait pour trait quand
|
|
aucun des deux ne parle. On interrompt alors une réparation à moitié
|
|
faite, ce qui est le pire des deux mondes.
|
|
"""
|
|
|
|
def test_the_pushed_script_announces_what_it_does(self):
|
|
source = cleanup.build_script(1, False)
|
|
self.assertIn(cleanup.STEP, source)
|
|
self.assertIn("flush=True", source)
|
|
|
|
def test_it_announces_each_pass(self):
|
|
source = cleanup.build_script(1, False)
|
|
self.assertIn('step("pass"', source)
|
|
|
|
def test_the_parent_relays_each_line_AS_IT_ARRIVES(self):
|
|
"""Le test comportemental, et non plus un mot cherché dans le code.
|
|
|
|
`capture_output=True` ne rend la main qu'à la fin : c'était la cause
|
|
du silence, pas la lenteur elle-même. On vérifie donc qu'une ligne
|
|
de progression ressort AVANT que le processus n'ait fini de parler.
|
|
"""
|
|
import io
|
|
|
|
lignes = [
|
|
f"{cleanup.STEP} modules 1/3 vieux_module\n",
|
|
"un journal Odoo sans rapport\n",
|
|
f"{cleanup.STEP} columns 2/3 res_partner.x\n",
|
|
f"{cleanup.START}\n",
|
|
'{"rounds": [], "missing": [], "failed": []}\n',
|
|
f"{cleanup.END}\n",
|
|
]
|
|
vu = []
|
|
|
|
class FauxProcessus:
|
|
def __init__(self, lst):
|
|
self.stdin = io.StringIO()
|
|
self.pid = -1
|
|
self._lst = lst
|
|
|
|
@property
|
|
def stdout(self):
|
|
# Un générateur : chaque ligne n'existe qu'au moment où on
|
|
# la lit, comme un vrai tube. Rendre la liste entière
|
|
# laisserait passer une lecture en bloc.
|
|
for rang, ligne in enumerate(self._lst):
|
|
vu.append(("lu", rang))
|
|
yield ligne
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
original = cleanup.subprocess.Popen
|
|
cleanup.subprocess.Popen = lambda *a, **kw: FauxProcessus(lignes)
|
|
self.addCleanup(setattr, cleanup.subprocess, "Popen", original)
|
|
report = cleanup.run_shell(
|
|
"db",
|
|
"./config.conf",
|
|
"script",
|
|
echo=lambda ligne: vu.append(("relayé", ligne)),
|
|
)
|
|
self.assertEqual(report["rounds"], [])
|
|
relayes = [x for x in vu if x[0] == "relayé"]
|
|
self.assertEqual(
|
|
[x[1] for x in relayes],
|
|
["modules 1/3 vieux_module", "columns 2/3 res_partner.x"],
|
|
)
|
|
# ET au fil de l'eau : le relais suit IMMÉDIATEMENT la lecture de
|
|
# sa ligne. « quelque part avant la fin » ne suffirait pas — une
|
|
# lecture en bloc suivie d'une boucle de relais passerait aussi.
|
|
self.assertEqual(vu[vu.index(("lu", 0)) + 1], relayes[0])
|
|
self.assertEqual(vu[vu.index(("lu", 2)) + 1], relayes[1])
|
|
|
|
def test_the_relay_shows_the_elapsed_time(self):
|
|
# Ce qui distingue « ça avance lentement » de « ça ne bouge plus ».
|
|
import io
|
|
from contextlib import redirect_stdout
|
|
|
|
import time as _time
|
|
|
|
echo = cleanup.make_echo(_time.monotonic() - 42)
|
|
out = io.StringIO()
|
|
with redirect_stdout(out):
|
|
echo("modules 2/9 stock_deposit")
|
|
self.assertIn("42", out.getvalue())
|
|
self.assertIn("stock_deposit", out.getvalue())
|
|
|
|
def test_the_slowness_is_announced_up_front(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
self.assertIn("reloads the whole registry", source)
|
|
|
|
def test_the_timeout_cannot_be_starved_by_silence(self):
|
|
# Un délai vérifié DANS la boucle de lecture ne se déclencherait
|
|
# jamais : cette boucle bloque tant qu'aucune ligne n'arrive, et
|
|
# c'est exactement le cas qu'il faut couvrir.
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.run_shell)
|
|
self.assertIn("threading.Timer", source)
|
|
|
|
def test_stopping_kills_the_whole_group(self):
|
|
# « ./odoo_bin.sh » est un script bash : un terminate() sur lui tue
|
|
# le script et laisse odoo-bin vivant, sur la base, avec ses
|
|
# verrous. La leçon a déjà été payée en serveurs orphelins.
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.kill_group)
|
|
self.assertIn("killpg", source)
|
|
self.assertIn(
|
|
"start_new_session", inspect.getsource(cleanup.run_shell)
|
|
)
|
|
|
|
def test_a_timeout_is_reported_as_such(self):
|
|
# Sans cela, un arrêt à l'expiration se lisait « le nettoyage n'a
|
|
# produit aucun rapport » — un diagnostic faux.
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.run_shell)
|
|
self.assertIn("was still running after", source)
|
|
self.assertLess(
|
|
source.index("was still running after"),
|
|
source.index("produced no report"),
|
|
)
|
|
|
|
|
|
class TestTheCascadeThatKilledEverything(unittest.TestCase):
|
|
"""Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une.
|
|
|
|
passe 1 : 0 purgés
|
|
⚠️ 0 purgés ; 7 n'ont pas pu l'être :
|
|
- [modules] - : savepoint "10eb6971..." does not exist
|
|
- [columns] - : current transaction is aborted, commands ignored
|
|
... et ainsi de suite jusqu'à la dernière.
|
|
|
|
Une seule panne, six victimes. La cause n'était pas dans OCA mais chez
|
|
nous : on n'a jamais remis la transaction d'aplomb après l'échec.
|
|
"""
|
|
|
|
def build(self):
|
|
journal = []
|
|
cursor = AbortingCursor(journal)
|
|
coupable = CommittingLine("colonne_morte", cursor, journal=journal)
|
|
models = {
|
|
cleanup.ORDER[1][1]: FakeModel([coupable]),
|
|
cleanup.ORDER[2][1]: FakeModel(
|
|
[FakeLine("suivante", journal=journal)]
|
|
),
|
|
cleanup.ORDER[3][1]: FakeModel(
|
|
[FakeLine("encore", journal=journal)]
|
|
),
|
|
}
|
|
return run_script(models, max_round=1, cursor=cursor)
|
|
|
|
def test_the_categories_after_it_still_work(self):
|
|
report, _got = self.build()
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
self.assertEqual(purged.get("tables"), 1)
|
|
|
|
def test_nobody_else_reports_an_aborted_transaction(self):
|
|
# C'est la SIGNATURE de la cascade : six lignes qui ne disent rien
|
|
# de leur propre catégorie, seulement qu'une autre a échoué avant.
|
|
report, _got = self.build()
|
|
contamines = [
|
|
entry
|
|
for round_ in report["rounds"]
|
|
for entry in round_
|
|
for _name, message in entry["errors"]
|
|
if "transaction is aborted" in message
|
|
]
|
|
self.assertEqual(contamines, [])
|
|
self.assertEqual(report["failed"], [])
|
|
|
|
def test_the_real_failure_is_still_reported(self):
|
|
# Rattraper ne veut pas dire taire : la colonne n'a PAS été purgée.
|
|
report, _got = self.build()
|
|
entry = [e for e in report["rounds"][0] if e["kind"] == "modules"][0]
|
|
self.assertEqual(entry["purged"], 0)
|
|
self.assertIn("savepoint", entry["errors"][0][1])
|
|
|
|
def test_the_transaction_is_put_back_on_its_feet(self):
|
|
_report, got = self.build()
|
|
self.assertIn(("rollback", None), got)
|
|
|
|
|
|
class TestADeadCreateDoesNotContaminate(unittest.TestCase):
|
|
def test_the_next_category_is_not_dragged_down(self):
|
|
# `create({})` qui échoue laisse la transaction avortée : sans
|
|
# rollback, la catégorie suivante mourait sur l'erreur d'une autre.
|
|
journal = []
|
|
cursor = AbortingCursor(journal)
|
|
|
|
class MortAuDepart(FakeModel):
|
|
def create(self, values):
|
|
cursor.aborted = True
|
|
raise RuntimeError("registre indisponible")
|
|
|
|
models = {
|
|
cleanup.ORDER[0][1]: MortAuDepart([]),
|
|
cleanup.ORDER[2][1]: FakeModel(
|
|
[FakeLine("colonne", journal=journal)]
|
|
),
|
|
}
|
|
report, _got = run_script(models, max_round=1, cursor=cursor)
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
self.assertIn("registre", report["failed"][0][2])
|
|
|
|
|
|
class TestGoingRoundAgain(unittest.TestCase):
|
|
def test_what_one_pass_refused_the_next_may_take(self):
|
|
# LE point, et la raison même de boucler : une entrée refuse TANT QUE
|
|
# sa voisine est là. Une ligne qui guérirait toute seule n'existe pas
|
|
# — ce qui existe, c'est une dépendance qui tombe.
|
|
etat = {"table_partie": False}
|
|
|
|
class Dependante(FakeLine):
|
|
def purge(self):
|
|
if not etat["table_partie"]:
|
|
raise RuntimeError("la table la retient encore")
|
|
self.fails = -1
|
|
|
|
class Liberatrice(FakeLine):
|
|
def purge(self):
|
|
etat["table_partie"] = True
|
|
self.fails = -1
|
|
|
|
modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1]
|
|
models = {
|
|
modele_col: FakeModel([Dependante("colonne_liee")]),
|
|
modele_tab: FakeModel([Liberatrice("vieille_table")]),
|
|
}
|
|
report, _got = run_script(models, max_round=5)
|
|
# Passe 1 : la colonne refuse, la table part → du progrès, on continue.
|
|
self.assertEqual(report["rounds"][0][0]["purged"], 0)
|
|
self.assertEqual(report["rounds"][0][1]["purged"], 1)
|
|
# Passe 2 : la colonne cède, sa dépendance étant partie.
|
|
self.assertEqual(report["rounds"][1][0]["purged"], 1)
|
|
|
|
def test_a_pass_that_repairs_nothing_ends_it(self):
|
|
# Rien n'a changé dans la base : une passe de plus rendrait le même
|
|
# refus. Boucler serait des minutes brûlées pour rien.
|
|
models = {
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
|
|
}
|
|
report, _got = run_script(models, max_round=8)
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
self.assertEqual(len(report["rounds"][0][0]["errors"]), 1)
|
|
|
|
def test_the_loop_stops_when_a_pass_repairs_nothing(self):
|
|
# Ce qui résistait au tour d'avant résistera encore : boucler
|
|
# jusqu'à max_round brûlerait des minutes pour rien.
|
|
models = {
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
|
|
}
|
|
report, _got = run_script(models, max_round=8)
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
def test_nothing_to_do_is_one_pass(self):
|
|
models = {cleanup.ORDER[0][1]: FakeModel([])}
|
|
report, _got = run_script(models, max_round=8)
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
class TestWhatIsAbsentIsSkipped(unittest.TestCase):
|
|
def test_a_missing_wizard_is_noted_not_fatal(self):
|
|
# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property
|
|
# par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur
|
|
# une version où il n'a plus lieu d'être.
|
|
models = {cleanup.ORDER[0][1]: FakeModel([])}
|
|
report, _got = run_script(models, max_round=1)
|
|
self.assertIn("properties", report["missing"])
|
|
self.assertIn("indexes", report["missing"])
|
|
|
|
def test_a_kind_is_noted_once(self):
|
|
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])}
|
|
report, _got = run_script(models, max_round=5)
|
|
self.assertEqual(report["missing"].count("properties"), 1)
|
|
|
|
|
|
class TestTheDryRun(unittest.TestCase):
|
|
def test_it_purges_nothing(self):
|
|
journal = []
|
|
lines = [FakeLine("a", journal=journal)]
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
report, got = run_script(models, max_round=5, dry_run=True)
|
|
self.assertEqual(journal, [])
|
|
self.assertNotIn(("commit", None), got)
|
|
self.assertEqual(report["rounds"][0][0]["would"], ["a"])
|
|
|
|
def test_it_undoes_what_merely_looking_caused(self):
|
|
# `purge_modules.find()` PURGE de lui-même : une simulation écrivait
|
|
# donc pour de bon, ce qui lui retire tout son sens. On défait ce
|
|
# que la lecture a provoqué — possible ici, justement parce qu'on
|
|
# n'a validé aucune entrée.
|
|
journal = []
|
|
models = {
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("a", journal=journal)])
|
|
}
|
|
_report, got = run_script(models, max_round=5, dry_run=True)
|
|
self.assertIn(("rollback", None), got)
|
|
self.assertNotIn(("commit", None), got)
|
|
|
|
def test_it_does_a_single_pass(self):
|
|
# Rien ne change, donc rien ne se libère : boucler serait du vent.
|
|
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])}
|
|
report, _got = run_script(models, max_round=9, dry_run=True)
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
class TestTheReport(unittest.TestCase):
|
|
def setUp(self):
|
|
from script.todo import todo_i18n
|
|
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
|
|
def test_leftovers_are_a_warning_not_a_failure(self):
|
|
report = {
|
|
"rounds": [
|
|
[
|
|
{
|
|
"kind": "models",
|
|
"purged": 3,
|
|
"errors": [["x", "held"]],
|
|
"would": [],
|
|
}
|
|
]
|
|
],
|
|
"missing": [],
|
|
"failed": [],
|
|
}
|
|
text = cleanup.render(report, "db")
|
|
self.assertIn("⚠️", text)
|
|
self.assertIn("not a failure", text)
|
|
|
|
def test_all_clean_says_so(self):
|
|
report = {
|
|
"rounds": [
|
|
[{"kind": "models", "purged": 3, "errors": [], "would": []}]
|
|
],
|
|
"missing": [],
|
|
"failed": [],
|
|
}
|
|
self.assertIn("✅", cleanup.render(report, "db"))
|
|
|
|
def test_a_dry_run_is_not_read_as_refusals(self):
|
|
# 586 candidats ne sont pas 586 refus : la première version les
|
|
# affichait comme des échecs.
|
|
report = {
|
|
"rounds": [
|
|
[
|
|
{
|
|
"kind": "columns",
|
|
"purged": 0,
|
|
"errors": [],
|
|
"would": ["a", "b"],
|
|
}
|
|
]
|
|
],
|
|
"missing": [],
|
|
"failed": [],
|
|
}
|
|
text = cleanup.render(report, "db")
|
|
self.assertIn("would be purged", text)
|
|
self.assertNotIn("⚠️", text)
|
|
|
|
|
|
class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
|
|
"""Interroger les pages sur une base encombrée fait chercher à côté."""
|
|
|
|
def source(self):
|
|
import inspect
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
def test_it_runs_before_both_smoke_tests(self):
|
|
source = self.source()
|
|
self.assertEqual(source.count("prompt_database_cleanup"), 2)
|
|
for _ in range(2):
|
|
nettoyage = source.index("prompt_database_cleanup")
|
|
mesure = source.index("prompt_smoke_public_url")
|
|
self.assertLess(nettoyage, mesure)
|
|
source = source[mesure + 1 :]
|
|
|
|
def test_it_gets_a_real_terminal(self):
|
|
import inspect
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_database_cleanup)
|
|
self.assertIn("run_tool(", source)
|
|
self.assertNotIn("todo_upgrade_execute", source)
|
|
capture = inspect.getsource(TodoUpgrade.run_captured)
|
|
self.assertIn("run_captured", inspect.getsource(TodoUpgrade.run_tool))
|
|
# Un pseudo-terminal, jamais un tube : derrière un tube
|
|
# `can_ask()` rend False et l'outil cesse de poser sa question.
|
|
self.assertIn("pty.spawn", capture)
|
|
self.assertNotIn("subprocess.PIPE", capture)
|
|
|
|
def test_saying_no_cleans_nothing(self):
|
|
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
|
import contextlib
|
|
import io
|
|
|
|
from script.todo import todo_i18n
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
upgrade.dct_progression = {}
|
|
upgrade.lst_command_executed = []
|
|
upgrade.write_config = lambda: None
|
|
lst_cmd = []
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
|
upgrade.ask_gate = lambda prompt, default="": "n" or default
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
upgrade.prompt_database_cleanup("db")
|
|
self.assertEqual(lst_cmd, [])
|
|
|
|
def test_the_default_cleans(self):
|
|
# Entrée nettoie : le nettoyage précède les tests de fumée, et une
|
|
# base encombrée de tables mortes fait échouer des pages pour une
|
|
# raison qui n'a rien à voir avec la migration.
|
|
import contextlib
|
|
import io
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
upgrade.dct_progression = {}
|
|
upgrade.lst_command_executed = []
|
|
upgrade.write_config = lambda: None
|
|
lst_cmd = []
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
|
upgrade.ask_gate = lambda prompt, default="": "" or default
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
upgrade.prompt_database_cleanup("db")
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
self.assertIn("database_cleanup.py", lst_cmd[0])
|
|
|
|
def test_yes_runs_it_on_that_database(self):
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
upgrade.dct_progression = {}
|
|
upgrade.lst_command_executed = []
|
|
upgrade.write_config = lambda: None
|
|
lst_cmd = []
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
|
upgrade.ask_gate = lambda prompt, default="": "y" or default
|
|
upgrade.prompt_database_cleanup("db_upgrade_18")
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
self.assertIn("database_cleanup.py", lst_cmd[0])
|
|
self.assertIn("-d db_upgrade_18", lst_cmd[0])
|
|
|
|
|
|
class TestNothingToPurgeIsNotAFailure(unittest.TestCase):
|
|
"""Le module signale le VIDE par une exception.
|
|
|
|
`raise UserError("No orphaned models found")` : le compter comme un
|
|
échec faisait passer une base saine pour une base cassée, avec quatre
|
|
avertissements sur cinq catégories.
|
|
"""
|
|
|
|
def test_the_pushed_script_separates_it(self):
|
|
source = cleanup.build_script(1, False)
|
|
self.assertIn("except UserError:", source)
|
|
vide = source.index("except UserError:")
|
|
echec = source.index('note(label, "-", exc)')
|
|
self.assertLess(vide, echec, "l'ordre des except décide")
|
|
|
|
def test_it_is_reported_as_zero_not_as_an_error(self):
|
|
install_fake_odoo_exceptions(self)
|
|
|
|
class Empty(FakeModel):
|
|
def create(self, values):
|
|
raise UserError("No orphaned models found")
|
|
|
|
models = {cleanup.ORDER[0][1]: Empty([])}
|
|
report, _got = run_script(models, max_round=1)
|
|
entry = report["rounds"][0][0]
|
|
self.assertEqual(entry["purged"], 0)
|
|
self.assertEqual(entry["errors"], [])
|
|
self.assertEqual(report["failed"], [])
|
|
|
|
|
|
class TestTheModuleIsInstalledFirst(unittest.TestCase):
|
|
"""Sans le module, aucun assistant n'existe et l'outil dit « rien ».
|
|
|
|
Ce silence se lit comme un succès. La migration l'installait à l'étape
|
|
3, donc APRÈS le nettoyage de l'étape 2 : l'ordre rendait l'outil
|
|
inutile au premier passage.
|
|
"""
|
|
|
|
def test_it_checks_the_state_before_cleaning(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
self.assertIn("module_state(", source)
|
|
self.assertLess(
|
|
source.index("module_state("), source.index("run_shell(")
|
|
)
|
|
|
|
def test_it_installs_when_absent(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
self.assertIn('state != "installed"', source)
|
|
self.assertIn("install_module(", source)
|
|
|
|
def test_a_failed_install_stops_there(self):
|
|
# Nettoyer sans le module rendrait « rien à faire » sur une base qui
|
|
# en avait besoin.
|
|
import inspect
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
install = source.index("install_module(")
|
|
self.assertIn("return 2", source[install : install + 400])
|
|
|
|
|
|
class TestTheMigrationNoLongerAsksTwice(unittest.TestCase):
|
|
def test_the_manual_cleanup_prompt_is_gone(self):
|
|
# Le faire à la main après l'avoir fait automatiquement.
|
|
import inspect
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
self.assertNotIn("Did you finish to clean database", source)
|
|
self.assertNotIn("Go to Settings / Technical / Cleanup", source)
|
|
|
|
def test_the_late_install_is_gone_too(self):
|
|
# Elle arrivait à l'étape 3, après l'usage de l'étape 2 : l'outil
|
|
# pose désormais le module lui-même, au bon moment.
|
|
import inspect
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
self.assertNotIn(
|
|
"install_addons.sh {database_name} database_cleanup", source
|
|
)
|
|
|
|
|
|
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
|
|
"""Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer."""
|
|
|
|
def test_a_mismatch_is_refused(self):
|
|
original_checkout = cleanup.checkout_version
|
|
original_db = cleanup.database_version
|
|
cleanup.checkout_version = lambda: "14.0"
|
|
cleanup.database_version = lambda database: "17.0"
|
|
self.addCleanup(
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
)
|
|
self.addCleanup(setattr, cleanup, "database_version", original_db)
|
|
message = cleanup.require_matching_version("db")
|
|
self.assertIsNotNone(message)
|
|
self.assertIn("14.0", message)
|
|
self.assertIn("17.0", message)
|
|
|
|
def test_a_match_passes(self):
|
|
original_checkout = cleanup.checkout_version
|
|
original_db = cleanup.database_version
|
|
cleanup.checkout_version = lambda: "17.0"
|
|
cleanup.database_version = lambda database: "17.0"
|
|
self.addCleanup(
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
)
|
|
self.addCleanup(setattr, cleanup, "database_version", original_db)
|
|
self.assertIsNone(cleanup.require_matching_version("db"))
|
|
|
|
def test_not_knowing_does_not_block(self):
|
|
# Refuser sur une supposition empêcherait de nettoyer là où c'est
|
|
# possible.
|
|
original_checkout = cleanup.checkout_version
|
|
cleanup.checkout_version = lambda: None
|
|
self.addCleanup(
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
)
|
|
self.assertIsNone(cleanup.require_matching_version("db"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|