erplibre/test/test_error_retry_loop.py
Mathieu Benoit 068022db82 [ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.

Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.

Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.

The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.

--- FR ---

Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.

Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.

En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.

La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00

239 lines
9.1 KiB
Python

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Réparer puis rejouer — et savoir s'arrêter.
Le motif d'échec le plus fréquent d'une migration est une copie COW en
retard sur sa vue module : « Element <xpath …> cannot be located in parent
view ». La réparation est connue, et c'est presque toujours ce qu'on allait
faire. D'où « 3 » par défaut, et le rejeu automatique derrière.
Mais un défaut qui agit doit savoir s'arrêter, et à deux titres :
- réinitialiser quand il n'y a RIEN à réinitialiser puis reproposer la même
chose est une boucle sans fin. Vécu : « Aucune copie COW n'a dérivé »,
encore et encore ;
- une réparation qui ne suffit pas relancerait la commande indéfiniment.
Trois tentatives, puis on rend la main : une migration lancée en
auto-exécution tournerait sinon toute la nuit sur le même échec.
Ces tests comptent les tours. C'est la seule façon de prouver qu'une
boucle se termine.
"""
import io
import os
import unittest
from contextlib import redirect_stdout
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class Harness(unittest.TestCase):
"""Un pilote dont la commande échoue toujours, et qu'on observe."""
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def upgrade(self, lst_answer=None, resets=None, echec=True):
"""`resets` : ce que la réinitialisation rend, tour après tour."""
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.lst_command_executed = []
obj.write_config = lambda: None
obj.database_from_command = lambda cmd: "db"
self.lst_run = []
self.lst_reset = []
class FauxExecute:
def exec_command_live(_self, cmd, **kw):
self.lst_run.append(cmd)
# Toujours en échec : c'est le cas qu'on veut borner.
return (1 if echec else 0), cmd
obj.execute = FauxExecute()
suite = iter(resets if resets is not None else [])
def faux_reset(database):
self.lst_reset.append(database)
try:
return next(suite)
except StopIteration:
return False
obj.prompt_reset_stale_cow_views = faux_reset
obj.check_stale_cow_views = lambda db: None
obj.run_on_terminal = lambda cmd: 0
reponses = iter(lst_answer or [])
def faux_ask(prompt, default=""):
try:
return next(reponses)
except StopIteration:
# Personne ne répond : c'est le mode auto, et c'est
# précisément là qu'une boucle sans fin se déclenche.
return default
obj.ask = faux_ask
return obj
def executer(self, obj):
out = io.StringIO()
with redirect_stdout(out):
obj.todo_upgrade_execute("./script/addons/update_addons_all.sh db")
return out.getvalue()
class TestRepairingThenReplaying(Harness):
def test_the_default_repairs(self):
# Sans réponse, on répare : c'est le geste qu'on allait faire.
obj = self.upgrade(resets=[True, False])
self.executer(obj)
self.assertTrue(self.lst_reset)
def test_a_successful_repair_replays_the_command(self):
obj = self.upgrade(resets=[True, False])
self.executer(obj)
self.assertGreaterEqual(len(self.lst_run), 2)
def test_a_repair_that_changed_nothing_does_NOT_replay(self):
# Rejouer sans avoir rien changé donnerait le même échec.
obj = self.upgrade(resets=[False])
self.executer(obj)
self.assertEqual(len(self.lst_run), 1)
class TestItAlwaysStops(Harness):
"""La propriété qui compte le plus : la boucle se termine."""
def test_nothing_to_reset_does_not_loop_forever(self):
# Vécu : « Aucune copie COW n'a dérivé », reproposé sans fin parce
# que le défaut restait « 3 ».
obj = self.upgrade(resets=[False, False, False, False, False])
self.executer(obj)
self.assertLessEqual(len(self.lst_reset), 2)
def test_a_repair_that_never_helps_stops_after_three(self):
obj = self.upgrade(resets=[True] * 20)
self.executer(obj)
self.assertEqual(len(self.lst_run), TodoUpgrade.MAX_ERROR_RETRY)
def test_giving_up_says_so_out_loud(self):
# S'arrêter en silence ferait croire que c'est réparé.
obj = self.upgrade(resets=[True] * 20)
text = self.executer(obj)
self.assertIn("needs a developer", text)
def test_the_loop_is_bounded_STRUCTURALLY(self):
"""Même si la bascule du défaut disparaissait un jour.
Deux protections pour la même panne, et c'est délibéré : celle-ci
ne dépend d'aucune logique métier. Une boucle infinie dans une
migration lancée sans surveillance coûte une nuit, et la seule
preuve qu'une boucle se termine est de compter ses tours.
"""
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
self.executer(obj)
self.assertLessEqual(len(self.lst_run), 2)
def test_the_turn_bound_says_so(self):
obj = self.upgrade(lst_answer=["2"] * 50, resets=[])
text = self.executer(obj)
self.assertIn("Too many turns", text)
def test_the_bound_is_three(self):
self.assertEqual(TodoUpgrade.MAX_ERROR_RETRY, 3)
def test_a_command_that_succeeds_never_asks(self):
obj = self.upgrade(echec=False)
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
class TestTheHumanKeepsTheWheel(Harness):
def test_typing_one_replays_without_consuming_the_budget(self):
# Le plafond borne le rejeu AUTOMATIQUE. Quelqu'un qui tape « 1 »
# sait ce qu'il fait, et se voir refuser un quatrième essai serait
# une surprise désagréable.
obj = self.upgrade(lst_answer=["1", "1", "1", "1", ""])
self.executer(obj)
self.assertEqual(len(self.lst_run), 5)
def test_typing_n_continues_without_repairing(self):
obj = self.upgrade(lst_answer=[""] * 0 + ["x"])
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
def test_the_prompt_says_what_enter_does(self):
import inspect
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
# éprouve, pas une méthode en particulier.
source = inspect.getsource(
TodoUpgrade.todo_upgrade_execute
) + inspect.getsource(TodoUpgrade._prompt_on_error)
self.assertIn('defaut = "3" if database_name else ""', source)
self.assertIn("default=defaut", source)
def test_without_a_database_the_default_is_to_continue(self):
# Les options 2 à 4 ne s'affichent pas : proposer « 3 » viserait
# une commande qui n'existe pas.
obj = self.upgrade()
obj.database_from_command = lambda cmd: None
self.executer(obj)
self.assertEqual(self.lst_reset, [])
self.assertEqual(len(self.lst_run), 1)
class TestTheResetReportsWhatItDid(unittest.TestCase):
"""Sans verdict, on ne peut pas décider de rejouer."""
def test_nothing_drifted_is_False(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.stale_cow_keys = lambda db: []
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
def test_saying_no_is_False(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": "n"
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
def test_a_reset_that_ran_is_True(self):
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.write_config = lambda: None
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": default
obj.run_on_terminal = lambda cmd: 0
with redirect_stdout(io.StringIO()):
self.assertTrue(obj.prompt_reset_stale_cow_views("db"))
def test_a_reset_that_FAILED_is_False(self):
# Rejouer derrière une réinitialisation qui a échoué, c'est brûler
# une tentative sur un état inchangé.
obj = TodoUpgrade.__new__(TodoUpgrade)
obj.dct_progression = {}
obj.write_config = lambda: None
obj.stale_cow_keys = lambda db: ["web.layout"]
obj.ask_gate = lambda prompt, default="": default
obj.run_on_terminal = lambda cmd: 2
with redirect_stdout(io.StringIO()):
self.assertFalse(obj.prompt_reset_stale_cow_views("db"))
if __name__ == "__main__":
unittest.main()