#!/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 [] def purge(self): self.journal.append(("purge", self.name)) if self.fails > 0: self.fails -= 1 raise RuntimeError(f"refus sur {self.name}") class FakeWizard: def __init__(self, lines): self.purge_line_ids = lines 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. return FakeWizard([ln for ln in self._lines if ln.fails >= 0]) class FakeCursor: def __init__(self, journal): self.journal = journal def savepoint(self): journal = self.journal class Guard: def __enter__(self_inner): journal.append(("savepoint", "enter")) return self_inner def __exit__(self_inner, exc_type, exc, tb): journal.append( ("savepoint", "rollback" if exc_type else "release") ) return False return Guard() def commit(self): self.journal.append(("commit", None)) class FakeEnv(dict): def __init__(self, mapping, journal): super().__init__(mapping) self.cr = FakeCursor(journal) def run_script(models, max_round=10, dry_run=False): """Exécuter le script réellement poussé, et rendre (rapport, journal).""" import json journal = [] env = FakeEnv(models, journal) 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_each_entry_gets_its_own_savepoint(self): # Sans cela, le premier refus emporterait tout ce que la passe avait # déjà réparé. 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) self.assertEqual(got.count(("savepoint", "enter")), 2) def test_a_refusal_rolls_back_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"]) self.assertEqual(got.count(("savepoint", "rollback")), 1) self.assertEqual(got.count(("savepoint", "release")), 2) 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 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_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_on_terminal", source) self.assertNotIn("todo_upgrade_execute", source) def test_the_default_cleans_nothing(self): # Cela ÉCRIT en base : ce n'est pas à la migration de le décider. 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_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt: "" with contextlib.redirect_stdout(io.StringIO()): upgrade.prompt_database_cleanup("db") self.assertEqual(lst_cmd, []) 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_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 upgrade.ask_gate = lambda prompt: "y" 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 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()