diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 0c7c22a..8b246df 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -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(",") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index eb2a1c6..c6abf99 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b50ff35..a08e0d4 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -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"): diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index 8b3173b..3c41c85 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -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