#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Une copie de site web qui ne sait plus se rendre après la montée. `check_cow_views` cherche la rupture qui ARRÊTE la migration : la forme que l'arch doit avoir a changé. Deux autres la laissent finir sans un mot et ne se voient qu'à l'ouverture de la page. Mesuré sur une migration 12 → 18 réelle : /contact rendait 500 depuis le palier 14 → 15 — quatre paliers de silence. La copie COW de `website.contactus` n'a jamais eu le `t-set='contactus_form_values'` que le module a gagné en chemin, et sur lequel sa vue héritière fait un xpath. Les deux copies appelaient en plus `website.company_description`, gabarit disparu en 18. """ import os import sys import unittest sys.path.append( os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) ) from script.odoo.migration import fix_cow_render as cow # noqa: E402 # La forme réelle, réduite : le module porte l'ancrage, la copie ne l'a # jamais eu, et toutes deux appellent le gabarit disparu. ARCH_MODULE = ( "" "" "" "" "

Contact us

" "
" ) ARCH_COPIE = ( "" "" "

Parlons de votre projet

" "
" "
" ) ARCH_ENFANT = ( "" "" ) class TestWhatAChildAsksFor(unittest.TestCase): def test_it_reads_the_xpath_of_a_child(self): self.assertEqual( cow.anchors_wanted(ARCH_ENFANT), ["//t[@t-set='contactus_form_values']"], ) def test_a_child_without_xpath_asks_for_nothing(self): self.assertEqual( cow.anchors_wanted(""), [] ) def test_an_unparsable_arch_asks_for_nothing_rather_than_crashing(self): self.assertEqual(cow.anchors_wanted("" "" "" "

Ma page

" "
" ) neuf = cow.repair_anchor( copie, ARCH_MODULE, "//t[@t-set='contactus_form_values']" ) hote = etree.fromstring(neuf.encode("utf-8")).xpath( "//t[@t-call='website.layout']" )[0] noms = [e.get("t-set") or e.get("id") for e in hote] self.assertEqual( ["deja_la", "contactus_form_values", "wrap"], noms, neuf ) def test_it_keeps_the_page_written_by_the_customer(self): # C'est tout l'enjeu : réinitialiser la vue rendrait la page # fonctionnelle et effacerait son contenu. neuf = cow.repair_anchor( ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']" ) self.assertIn("Parlons de votre projet", neuf) def test_it_refuses_when_the_host_element_has_no_twin(self): # Mieux vaut une réparation refusée qu'un bloc posé au hasard au # milieu de la page de quelqu'un. etrangere = "
" self.assertIsNone( cow.repair_anchor( etrangere, ARCH_MODULE, "//t[@t-set='contactus_form_values']" ) ) def test_it_refuses_when_the_module_has_no_anchor_either(self): self.assertIsNone( cow.repair_anchor(ARCH_COPIE, ARCH_COPIE, "//t[@t-set='absent']") ) def test_it_refuses_an_unparsable_arch(self): self.assertIsNone(cow.repair_anchor("", "fr_CA": ""})), ["en_US", "fr_CA"], ) def test_the_query_never_pins_one_language(self): # C'est ICI que le défaut est né : `arch_db->>'en_US'` ramenait # une seule langue, l'audit déclarait la vue saine et /contact # restait en 500 parce que le site rend en fr_CA. La requête doit # ramener l'OBJET entier. requete = cow.arch_expr(jsonb=True) self.assertNotIn("en_US", requete) self.assertNotIn("->>", requete) self.assertIn("arch_db", requete) def test_a_varchar_column_is_wrapped_as_one_nameless_language(self): # Une seule forme en aval : le reste du code n'a pas à savoir si # la version traduit l'arch ou non. requete = cow.arch_expr(jsonb=False) self.assertIn("json_build_object", requete) def test_a_plain_string_is_the_language_less_case(self): self.assertEqual(cow.langs_of(""), {"": ""}) def test_a_non_string_value_is_ignored(self): self.assertEqual( cow.langs_of({"en_US": "", "x": 3}), {"en_US": ""} ) def test_the_xpath_of_a_child_is_read_from_any_language(self): # Les xpath ne sont pas traduits : n'importe quelle langue suffit. self.assertEqual( cow.anchors_wanted(cow.any_lang({"fr_CA": ARCH_ENFANT})), ["//t[@t-set='contactus_form_values']"], ) class TestTheSqlItWrites(unittest.TestCase): def test_it_writes_all_the_languages_in_one_update(self): # Deux UPDATE sur la même vue et le second effacerait le premier ; # un seul portant une langue laisserait l'autre cassée. sql = cow.write_arch_sql( 7, {"en_US": "", "fr_CA": ""}, jsonb=True ) self.assertEqual(1, sql.count("UPDATE"), sql) self.assertIn("en_US", sql) self.assertIn("fr_CA", sql) self.assertIn("", sql) self.assertIn("", sql) self.assertIn("WHERE id = 7", sql) def test_the_varchar_case_writes_the_text_itself(self): sql = cow.write_arch_sql(7, {"": ""}, jsonb=False) self.assertNotIn("jsonb_build_object", sql) self.assertIn("", sql) def test_both_branches_survive_an_apostrophe(self): # La 16 stocke en varchar, la 17 et la 18 en jsonb : les DEUX # chemins écrivent, et n'éprouver que l'un laisse l'autre casser # au premier attribut cité. arch = "" for jsonb, langues in ((True, {"en_US": arch}), (False, {"": arch})): with self.subTest(jsonb=jsonb): sql = cow.write_arch_sql(7, langues, jsonb=jsonb) self.assertIn("$elcow$" + arch + "$elcow$", sql) class TestTheReport(unittest.TestCase): def rapport(self): return { "database": "essai", "jsonb": True, "views": [ { "id": 1228, "key": "website.contactus", "anchors": [{"enfant": 2782, "expr": "//t[@t-set='x']"}], "calls": ["website.company_description"], } ], } def test_a_clean_database_says_so(self): texte = "\n".join( cow.render({"database": "x", "jsonb": True, "views": []}, []) ) self.assertIn("✅", texte) def test_it_names_the_view_the_child_and_the_template(self): texte = "\n".join(cow.render(self.rapport(), [])) self.assertIn("1228", texte) self.assertIn("2782", texte) self.assertIn("website.company_description", texte) def test_it_warns_that_removing_a_call_removes_a_block(self): # Le message passe par `t()` : le comparer à un mot anglais en # dur le fait tomber dès qu'on le traduit. On demande la MÊME # traduction que le code, ce qui éprouve l'avertissement sans # épouser une langue. gestes = [(1228, "k", "t-call", "website.company_description", "SQL")] texte = "\n".join(cow.render(self.rapport(), gestes)) self.assertIn( cow.t("Removing a call removes its block from the page."), texte ) def test_it_stays_silent_about_blocks_when_nothing_is_removed(self): gestes = [(1228, "k", "anchor", "//t", "SQL")] texte = "\n".join(cow.render(self.rapport(), gestes)) self.assertNotIn( cow.t("Removing a call removes its block from the page."), texte ) def test_it_says_when_a_repair_is_out_of_reach(self): gestes = [(1228, "k", "anchor-impossible", "//t", None)] texte = "\n".join(cow.render(self.rapport(), gestes)) self.assertIn("1", texte) self.assertIn("⚠", texte) class TestItRunsInsideTheMigration(unittest.TestCase): """La réparation doit tourner APRÈS OpenUpgrade, pas avant. Elle prend l'ancrage manquant dans la vue MODULE, qui ne porte l'arch de la version cible qu'une fois la migration passée. Lancée avant, elle recopierait l'ancien ancrage — ou rien. """ def pilote(self): chemin = os.path.join( os.path.dirname(__file__), "..", "script", "todo", "todo_upgrade.py", ) with open(chemin, encoding="utf-8") as handle: return handle.read() def corps_de_la_reparation(self): """Le CODE de la méthode, docstring retirée. La docstring nomme `wait_at_error=False` pour l'expliquer : un test qui lit tout le texte le trouve même quand l'argument a disparu, et croit garder ce qu'il a laissé partir. Mesuré. """ import ast for noeud in ast.walk(ast.parse(self.pilote())): if ( isinstance(noeud, ast.FunctionDef) and noeud.name == "repair_cow_render" ): sans_texte = [ n for n in noeud.body if not ( isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant) and isinstance(n.value.value, str) ) ] return ast.dump(ast.Module(body=sans_texte, type_ignores=[])) return "" def test_the_scan_finds_the_method_at_all(self): # Sans cette borne, les tests suivants passeraient sur une chaîne # vide le jour où la méthode est renommée. self.assertTrue(self.corps_de_la_reparation()) def test_the_driver_calls_the_repair(self): self.assertIn("repair_cow_render", self.pilote()) def test_it_applies_rather_than_only_reporting(self): corps = self.corps_de_la_reparation() self.assertIn("fix_cow_render.py", corps) self.assertIn("--apply", corps) def test_a_finding_does_not_open_the_error_menu(self): # Le code 1 de cet outil veut dire « j'ai trouvé », pas « je suis # tombé ». Sans ce drapeau le pilote s'arrête sur une réparation # réussie. corps = self.corps_de_la_reparation() self.assertIn("wait_at_error", corps) self.assertIn("value=False", corps) def test_it_is_called_after_the_data_migration(self): source = self.pilote() migration = source.index("cmd_upgrade,\n new_env=") appel = source.index("self.repair_cow_render(") self.assertLess( migration, appel, "la réparation doit suivre OpenUpgrade : avant, la vue module" " porte encore l'arch de l'ancienne version", ) def test_a_missing_tool_is_not_a_crash(self): # Un vieux checkout n'a pas le fichier ; la migration ne doit pas # s'arrêter pour autant. corps = self.corps_de_la_reparation() self.assertIn("isfile", corps) if __name__ == "__main__": unittest.main()