[FIX] migration: a skipped back office must not read as a healthy one

The back-office pass was already there, and it works. What did not work
was the way it declined: one discreet line at the end of a long report,
saying « the database was not neutralized ». On six databases of a real
migration the test user is present up to the 15 bump and GONE at 17 and
18 — so the pass stopped silently exactly where a migration does the most
damage, and said something that was not even true.

The migration knows what it neutralized, so it now asks for the back
office by name. A missing test user on a database it neutralized is a
finding, printed loudly and counted as a failure. A missing tool is too:
returning None made the whole pass vanish without a word.

And it says UP FRONT which passes will run, on that database, by name.

--- FR ---

[FIX] migration : un back-office sauté ne doit pas se lire comme un sain

La passe back-office était déjà là et elle fonctionne. Ce qui ne
fonctionnait pas, c'est sa façon de renoncer : une ligne discrète en fin
d'un long rapport, disant « la base n'a pas été neutralisée ». Sur les six
bases d'une vraie migration, l'utilisateur test est présent jusqu'au
palier 15 et ABSENT en 17 et 18 — la passe s'arrêtait donc sans bruit là
où une migration fait le plus de dégâts, en disant quelque chose de faux.

La migration sait ce qu'elle a neutralisé : elle réclame désormais le
back-office. Un utilisateur test manquant sur une base qu'elle a
neutralisée est une trouvaille, affichée fort et comptée comme un échec.
Un outil absent aussi : rendre None faisait disparaître la passe entière.

Et elle annonce AVANT de lancer ce qui sera parcouru.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 03:21:25 -04:00
parent 1c7664409e
commit fc23bd6f96
4 changed files with 186 additions and 6 deletions

View file

@ -445,6 +445,14 @@ def render_internal(internal):
if internal is None:
return False
if "skipped" in internal:
if internal.get("loud"):
# Un saut ATTENDU se dit à voix basse ; un saut qui trahit une
# panne doit compter comme un échec, sinon le code de sortie
# annonce que tout va bien.
print(
f"\n⚠️ {t('Back office NOT browsed')} : {internal['skipped']}"
)
return True
print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}")
return False
import smoke_internal_ui
@ -481,6 +489,7 @@ def internal_phase(
limit=20,
every_menu=False,
lst_portal=None,
required=False,
):
"""Le back-office, si la base a été neutralisée. Rend None sinon.
@ -498,14 +507,32 @@ def internal_phase(
try:
import smoke_internal_ui
except ImportError:
return None
# Se taire ici ferait disparaître la passe ENTIÈRE sans un mot, et
# l'on croirait le back-office testé. Un outil absent est une
# panne d'installation, pas une base saine.
return {"skipped": t("smoke_internal_ui.py is missing"), "loud": True}
etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
if etat == "absent":
# `required` dit que la migration a NEUTRALISÉ cette base : le
# compte devrait donc y être. Mesuré — il survit jusqu'au palier
# 15 puis disparaît, et la passe s'arrêtait sans bruit exactement
# là où une migration fait le plus de dégâts.
if required:
return {
"skipped": t(
"the test user is gone from a neutralized"
" database: the back office was NOT checked"
),
"loud": True,
}
return {"skipped": t("no test user: the database was not neutralized")}
if etat != "present":
# « je ne sais pas » n'est pas « tout va bien » : le dire autrement
# ferait passer un back-office jamais ouvert pour un back-office sain.
return {"skipped": t("could not tell whether the test user exists")}
return {
"skipped": t("could not tell whether the test user exists"),
"loud": True,
}
try:
lst_result, lst_failure = smoke_internal_ui.run(
base_url,
@ -541,6 +568,7 @@ def run(
internal_limit=20,
every_menu=False,
portal=None,
internal_required=False,
):
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
@ -585,6 +613,7 @@ def run(
limit=internal_limit,
every_menu=every_menu,
lst_portal=portal,
required=internal_required,
)
finally:
stop_server(server)
@ -720,6 +749,11 @@ def main(argv=None):
action="store_true",
help="open every menu with an action, not just each app's first page",
)
parser.add_argument(
"--internal-required",
action="store_true",
help="the database WAS neutralized: a missing test user is a failure",
)
parser.add_argument(
"--portal",
default="/my",
@ -748,6 +782,7 @@ def main(argv=None):
internal_password=config.password,
internal_limit=config.record_limit,
every_menu=config.all_menus,
internal_required=config.internal_required,
portal=[
path.strip()
for path in (config.portal or "").split(",")

View file

@ -5498,6 +5498,38 @@ TRANSLATIONS = {
"fr": "appuyez pour continuer",
"en": "press to continue",
},
"smoke_internal_ui.py is missing": {
"fr": "smoke_internal_ui.py est absent",
"en": "smoke_internal_ui.py is missing",
},
"the test user is gone from a neutralized database: the back office was NOT checked": {
"fr": "l'utilisateur test a disparu d'une base neutralisée : le back-office n'a PAS été vérifié",
"en": "the test user is gone from a neutralized database: the back office was NOT checked",
},
"Back office NOT browsed": {
"fr": "Back-office NON parcouru",
"en": "Back office NOT browsed",
},
"Public pages, then the back office and /my as the": {
"fr": "Les pages publiques, puis le back-office et /my avec",
"en": "Public pages, then the back office and /my as the",
},
"test user (neutralized database).": {
"fr": "l'utilisateur test (base neutralisée).",
"en": "test user (neutralized database).",
},
"Public pages only:": {
"fr": "Pages publiques seulement :",
"en": "Public pages only:",
},
"was not neutralized, so there is no test user to": {
"fr": "n'a pas été neutralisée, il n'y a donc pas d'utilisateur test pour",
"en": "was not neutralized, so there is no test user to",
},
"sign in with.": {
"fr": "s'y connecter.",
"en": "sign in with.",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -3058,14 +3058,27 @@ class TodoUpgrade:
)
if answer != "y":
return
print(
f" {t('Public pages, then the back office as the test user')}"
f" {t('if the database was neutralized.')}"
)
# DIRE ce qui sera parcouru, et le dire AVANT. « si la base a été
# neutralisée » laissait la question ouverte pendant tout le
# parcours, et un saut annoncé en une ligne à la fin d'un long
# rapport ne se voit pas : on croit alors le back-office testé.
neutralise = "_neutralize" in database_name
if neutralise:
print(
f" {t('Public pages, then the back office and /my as the')}"
f" {t('test user (neutralized database).')}"
)
else:
print(
f" {t('Public pages only:')} '{database_name}'"
f" {t('was not neutralized, so there is no test user to')}"
f" {t('sign in with.')}"
)
self.run_on_terminal(
f"{PYTHON_BIN}"
" ./script/odoo/migration/smoke_public_url.py"
f" -d {database_name}"
+ (" --internal-required" if neutralise else "")
)
def show_cow_drift(self, database_name, next_version, mode="diff"):

View file

@ -404,6 +404,106 @@ class TestTheViewsAreRenderedServerSide(unittest.TestCase):
self.assertEqual(appels, ["get_views", "load_views", "load_views"])
class TestASkipThatHidesAFailure(unittest.TestCase):
"""Tous les sauts ne se valent pas, et c'est mesuré.
L'utilisateur `test` survit aux paliers 12 à 15 puis DISPARAÎT : relevé
sur les six bases d'une vraie migration, présent jusqu'à 15, absent en
17 et 18. La passe back-office s'arrêtait donc sans bruit exactement là
où une migration fait le plus de dégâts, et le rapport disait
tranquillement « la base n'a pas été neutralisée » — ce qui était faux.
`required` porte ce que la migration SAIT : elle a neutralisé cette
base, le compte devrait y être. Son absence devient alors une
trouvaille, pas une formalité.
"""
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"
import smoke_public_url
self.public = smoke_public_url
original = smoke_public_url.run_psql
self.addCleanup(setattr, smoke_public_url, "run_psql", original)
def phase(self, rows, **kw):
self.public.run_psql = lambda db, sql: rows
return self.public.internal_phase("http://x", "db", enabled=True, **kw)
def test_no_user_on_a_plain_database_is_a_quiet_skip(self):
rapport = self.phase([["0"]])
self.assertIn("not neutralized", rapport["skipped"])
self.assertFalse(rapport.get("loud"))
def test_no_user_on_a_NEUTRALIZED_database_is_a_finding(self):
rapport = self.phase([["0"]], required=True)
self.assertIn("NOT checked", rapport["skipped"])
self.assertTrue(rapport["loud"])
def test_a_finding_counts_as_a_failure(self):
# Sinon le code de sortie annonce que tout va bien alors que le
# back-office n'a jamais été ouvert.
import contextlib
import io
out = io.StringIO()
with contextlib.redirect_stdout(out):
echec = self.public.render_internal(
{"skipped": "disparu", "loud": True}
)
self.assertTrue(echec)
self.assertIn("NOT browsed", out.getvalue())
def test_a_quiet_skip_does_not(self):
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(
self.public.render_internal({"skipped": "pas neutralisée"})
)
def test_an_unreadable_database_is_never_silent(self):
rapport = self.phase([])
self.assertTrue(rapport["loud"])
def test_a_missing_tool_is_never_silent(self):
# `except ImportError: return None` faisait disparaître la passe
# ENTIÈRE sans un mot, et l'on croyait le back-office testé.
import inspect
source = inspect.getsource(self.public.internal_phase)
debut = source.index("except ImportError")
self.assertIn("loud", source[debut : debut + 400])
def test_the_migration_asks_for_it_when_it_neutralized(self):
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url)
self.assertIn("--internal-required", source)
self.assertIn('"_neutralize" in database_name', source)
def test_the_migration_says_UP_FRONT_what_will_be_browsed(self):
# Un saut annoncé en une ligne à la fin d'un long rapport ne se
# voit pas : on croit alors le back-office testé.
import inspect
from script.todo.todo_upgrade import TodoUpgrade
source = inspect.getsource(TodoUpgrade.prompt_smoke_public_url)
self.assertLess(
source.index("Public pages only:"),
source.index("run_on_terminal"),
)
class TestThePortalPage(unittest.TestCase):
"""/my n'est ni le site public ni le back-office : c'est un troisième
rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur