[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:
parent
1c7664409e
commit
fc23bd6f96
4 changed files with 186 additions and 6 deletions
|
|
@ -445,6 +445,14 @@ def render_internal(internal):
|
||||||
if internal is None:
|
if internal is None:
|
||||||
return False
|
return False
|
||||||
if "skipped" in internal:
|
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']}")
|
print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}")
|
||||||
return False
|
return False
|
||||||
import smoke_internal_ui
|
import smoke_internal_ui
|
||||||
|
|
@ -481,6 +489,7 @@ def internal_phase(
|
||||||
limit=20,
|
limit=20,
|
||||||
every_menu=False,
|
every_menu=False,
|
||||||
lst_portal=None,
|
lst_portal=None,
|
||||||
|
required=False,
|
||||||
):
|
):
|
||||||
"""Le back-office, si la base a été neutralisée. Rend None sinon.
|
"""Le back-office, si la base a été neutralisée. Rend None sinon.
|
||||||
|
|
||||||
|
|
@ -498,14 +507,32 @@ def internal_phase(
|
||||||
try:
|
try:
|
||||||
import smoke_internal_ui
|
import smoke_internal_ui
|
||||||
except ImportError:
|
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)
|
etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
|
||||||
if etat == "absent":
|
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")}
|
return {"skipped": t("no test user: the database was not neutralized")}
|
||||||
if etat != "present":
|
if etat != "present":
|
||||||
# « je ne sais pas » n'est pas « tout va bien » : le dire autrement
|
# « 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.
|
# 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:
|
try:
|
||||||
lst_result, lst_failure = smoke_internal_ui.run(
|
lst_result, lst_failure = smoke_internal_ui.run(
|
||||||
base_url,
|
base_url,
|
||||||
|
|
@ -541,6 +568,7 @@ def run(
|
||||||
internal_limit=20,
|
internal_limit=20,
|
||||||
every_menu=False,
|
every_menu=False,
|
||||||
portal=None,
|
portal=None,
|
||||||
|
internal_required=False,
|
||||||
):
|
):
|
||||||
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
|
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
|
||||||
|
|
||||||
|
|
@ -585,6 +613,7 @@ def run(
|
||||||
limit=internal_limit,
|
limit=internal_limit,
|
||||||
every_menu=every_menu,
|
every_menu=every_menu,
|
||||||
lst_portal=portal,
|
lst_portal=portal,
|
||||||
|
required=internal_required,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
stop_server(server)
|
stop_server(server)
|
||||||
|
|
@ -720,6 +749,11 @@ def main(argv=None):
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="open every menu with an action, not just each app's first page",
|
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(
|
parser.add_argument(
|
||||||
"--portal",
|
"--portal",
|
||||||
default="/my",
|
default="/my",
|
||||||
|
|
@ -748,6 +782,7 @@ def main(argv=None):
|
||||||
internal_password=config.password,
|
internal_password=config.password,
|
||||||
internal_limit=config.record_limit,
|
internal_limit=config.record_limit,
|
||||||
every_menu=config.all_menus,
|
every_menu=config.all_menus,
|
||||||
|
internal_required=config.internal_required,
|
||||||
portal=[
|
portal=[
|
||||||
path.strip()
|
path.strip()
|
||||||
for path in (config.portal or "").split(",")
|
for path in (config.portal or "").split(",")
|
||||||
|
|
|
||||||
|
|
@ -5498,6 +5498,38 @@ TRANSLATIONS = {
|
||||||
"fr": "appuyez pour continuer",
|
"fr": "appuyez pour continuer",
|
||||||
"en": "press to continue",
|
"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?": {
|
"Clean the database before testing the pages?": {
|
||||||
"fr": "Nettoyer la base avant de tester les pages ?",
|
"fr": "Nettoyer la base avant de tester les pages ?",
|
||||||
"en": "Clean the database before testing the pages?",
|
"en": "Clean the database before testing the pages?",
|
||||||
|
|
|
||||||
|
|
@ -3058,14 +3058,27 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
if answer != "y":
|
if answer != "y":
|
||||||
return
|
return
|
||||||
print(
|
# DIRE ce qui sera parcouru, et le dire AVANT. « si la base a été
|
||||||
f" {t('Public pages, then the back office as the test user')}"
|
# neutralisée » laissait la question ouverte pendant tout le
|
||||||
f" {t('if the database was neutralized.')}"
|
# 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(
|
self.run_on_terminal(
|
||||||
f"{PYTHON_BIN}"
|
f"{PYTHON_BIN}"
|
||||||
" ./script/odoo/migration/smoke_public_url.py"
|
" ./script/odoo/migration/smoke_public_url.py"
|
||||||
f" -d {database_name}"
|
f" -d {database_name}"
|
||||||
|
+ (" --internal-required" if neutralise else "")
|
||||||
)
|
)
|
||||||
|
|
||||||
def show_cow_drift(self, database_name, next_version, mode="diff"):
|
def show_cow_drift(self, database_name, next_version, mode="diff"):
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,106 @@ class TestTheViewsAreRenderedServerSide(unittest.TestCase):
|
||||||
self.assertEqual(appels, ["get_views", "load_views", "load_views"])
|
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):
|
class TestThePortalPage(unittest.TestCase):
|
||||||
"""/my n'est ni le site public ni le back-office : c'est un troisième
|
"""/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
|
rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue