[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. It says nothing about
the back office, which is where a migration does most of its damage: a
field dropped from a model but still named in a form, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit rien du
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
champ retiré du modèle mais toujours nommé dans un formulaire, un module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""Ouvrir le back-office comme quelqu'un qui s'y connecte.
|
|
|
|
|
|
|
|
|
|
Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit RIEN du
|
|
|
|
|
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
|
|
|
|
|
champ retiré du modèle mais toujours nommé dans un formulaire, un modèle
|
|
|
|
|
dont le code n'accompagne plus la version cible. Rien de tout cela n'arrête
|
|
|
|
|
le chargement des modules ; cela arrête le jour où quelqu'un ouvre l'appli.
|
|
|
|
|
|
|
|
|
|
Ce que ces tests verrouillent est ce que l'exécution réelle a corrigé — et
|
|
|
|
|
chaque point ci-dessous a d'abord été un vrai défaut, mesuré sur une base
|
|
|
|
|
18.0 de 25 applications :
|
|
|
|
|
|
|
|
|
|
- `web_search_read` a changé de signature en 17 (`fields` est devenu
|
|
|
|
|
`specification`). Seize applications sur vingt-deux échouaient sur MON
|
|
|
|
|
appel. `search_read` n'a pas bougé depuis la 12 ;
|
|
|
|
|
- un modèle absent du registre rend un « 404 Not Found » nu, illisible,
|
|
|
|
|
alors que c'est la trouvaille la plus nette d'une migration ;
|
|
|
|
|
- lire les champs d'un arch à l'expression régulière ramasse ceux des
|
|
|
|
|
SOUS-VUES : neuf applications rapportées « nommant un champ absent »,
|
|
|
|
|
toutes fausses, parce que les lignes d'une facture ne sont pas des
|
|
|
|
|
champs de la facture ;
|
|
|
|
|
- `run_psql` rend une liste vide aussi bien pour « aucune ligne » que pour
|
|
|
|
|
« requête refusée » : un SQL fautif faisait dire « base non neutralisée ».
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
|
|
|
|
|
|
|
|
|
import smoke_internal_ui as ui # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFindingTheFirstPageOfEachApp(unittest.TestCase):
|
|
|
|
|
"""Ce que le client web ouvre au clic : le premier menu avec une action."""
|
|
|
|
|
|
|
|
|
|
MENUS = [
|
|
|
|
|
{
|
|
|
|
|
"id": 1,
|
|
|
|
|
"name": "Ventes",
|
|
|
|
|
"parent_id": False,
|
|
|
|
|
"sequence": 10,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 2,
|
|
|
|
|
"name": "Commandes",
|
|
|
|
|
"parent_id": [1, "Ventes"],
|
|
|
|
|
"sequence": 5,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 3,
|
|
|
|
|
"name": "Devis",
|
|
|
|
|
"parent_id": [2, "Commandes"],
|
|
|
|
|
"sequence": 1,
|
|
|
|
|
"action": "ir.actions.act_window,42",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 4,
|
|
|
|
|
"name": "Clients",
|
|
|
|
|
"parent_id": [1, "Ventes"],
|
|
|
|
|
"sequence": 9,
|
|
|
|
|
"action": "ir.actions.act_window,43",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 5,
|
|
|
|
|
"name": "Réglages",
|
|
|
|
|
"parent_id": False,
|
|
|
|
|
"sequence": 99,
|
|
|
|
|
"action": "ir.actions.act_window,7",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 6,
|
|
|
|
|
"name": "Vide",
|
|
|
|
|
"parent_id": False,
|
|
|
|
|
"sequence": 50,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_it_descends_to_the_first_actionable_menu(self):
|
|
|
|
|
trouve = dict(
|
|
|
|
|
(app["name"], first["name"] if first else None)
|
|
|
|
|
for app, first in ui.apps(self.MENUS)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(trouve["Ventes"], "Devis")
|
|
|
|
|
|
|
|
|
|
def test_sequence_decides_not_the_id(self):
|
|
|
|
|
# « Commandes » (5) passe avant « Clients » (9) : l'ordre affiché
|
|
|
|
|
# est celui que l'utilisateur voit, pas l'ordre d'insertion.
|
|
|
|
|
trouve = dict(
|
|
|
|
|
(app["name"], first["name"] if first else None)
|
|
|
|
|
for app, first in ui.apps(self.MENUS)
|
|
|
|
|
)
|
|
|
|
|
self.assertNotEqual(trouve["Ventes"], "Clients")
|
|
|
|
|
|
|
|
|
|
def test_an_app_carrying_its_own_action_is_its_own_first_page(self):
|
|
|
|
|
trouve = dict(
|
|
|
|
|
(app["name"], first["name"] if first else None)
|
|
|
|
|
for app, first in ui.apps(self.MENUS)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(trouve["Réglages"], "Réglages")
|
|
|
|
|
|
|
|
|
|
def test_an_app_without_any_action_is_not_an_error(self):
|
|
|
|
|
# Elle n'a rien à ouvrir. La compter comme un échec ferait chercher
|
|
|
|
|
# un dégât là où il n'y a qu'un menu de regroupement.
|
|
|
|
|
trouve = dict(
|
|
|
|
|
(app["name"], first["name"] if first else None)
|
|
|
|
|
for app, first in ui.apps(self.MENUS)
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(trouve["Vide"])
|
|
|
|
|
|
|
|
|
|
def test_a_menu_loop_does_not_hang(self):
|
|
|
|
|
# Une base migrée porte parfois des données incohérentes ; un
|
|
|
|
|
# parcours naïf tournerait indéfiniment sans rien dire.
|
|
|
|
|
boucle = [
|
|
|
|
|
{
|
|
|
|
|
"id": 1,
|
|
|
|
|
"name": "A",
|
|
|
|
|
"parent_id": False,
|
|
|
|
|
"sequence": 1,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 2,
|
|
|
|
|
"name": "B",
|
|
|
|
|
"parent_id": [1, "A"],
|
|
|
|
|
"sequence": 1,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": 3,
|
|
|
|
|
"name": "C",
|
|
|
|
|
"parent_id": [2, "B"],
|
|
|
|
|
"sequence": 1,
|
|
|
|
|
"action": False,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
boucle[0]["parent_id"] = [3, "C"]
|
|
|
|
|
self.assertIsInstance(ui.apps(boucle), list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadingTheFieldsOfAPage(unittest.TestCase):
|
|
|
|
|
"""Les champs de la page, et RIEN de ce qui appartient aux sous-vues."""
|
|
|
|
|
|
|
|
|
|
FORM = {
|
|
|
|
|
"views": {
|
|
|
|
|
"form": {
|
|
|
|
|
"arch": (
|
|
|
|
|
"<form>"
|
|
|
|
|
'<field name="partner_id"/>'
|
|
|
|
|
'<field name="line_ids">'
|
|
|
|
|
'<list><field name="product_id"/>'
|
|
|
|
|
'<field name="price_unit"/></list>'
|
|
|
|
|
"</field>"
|
|
|
|
|
'<field name="amount_total"/>'
|
|
|
|
|
"</form>"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def test_the_top_level_fields_are_taken(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.arch_fields(self.FORM),
|
|
|
|
|
["partner_id", "line_ids", "amount_total"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_fields_of_an_embedded_view_are_NOT(self):
|
|
|
|
|
# Le défaut mesuré : `product_id` et `price_unit` sont des champs de
|
|
|
|
|
# la LIGNE de facture. Les attribuer à la facture faisait rapporter
|
|
|
|
|
# neuf applications cassées qui allaient parfaitement bien.
|
|
|
|
|
lst = ui.arch_fields(self.FORM)
|
|
|
|
|
self.assertNotIn("product_id", lst)
|
|
|
|
|
self.assertNotIn("price_unit", lst)
|
|
|
|
|
|
|
|
|
|
def test_a_dotted_name_is_left_alone(self):
|
|
|
|
|
views = {"a": {"arch": '<list><field name="partner_id.name"/></list>'}}
|
|
|
|
|
self.assertEqual(ui.arch_fields(views), [])
|
|
|
|
|
|
|
|
|
|
def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self):
|
|
|
|
|
views = {"a": {"arch": "<form><field name='x'"}}
|
|
|
|
|
self.assertEqual(ui.arch_fields(views), [])
|
|
|
|
|
|
|
|
|
|
def test_it_finds_arch_wherever_the_version_put_it(self):
|
|
|
|
|
# `views` en 16+, `fields_views` avant : chercher à plat évite de
|
|
|
|
|
# tenir deux chemins en dur qui dérivent l'un de l'autre.
|
|
|
|
|
ancien = {
|
|
|
|
|
"fields_views": {
|
|
|
|
|
"list": {"arch": '<list><field name="x"/></list>'}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.assertEqual(ui.arch_fields(ancien), ["x"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheThreeStatesOfTheTestUser(unittest.TestCase):
|
|
|
|
|
"""« je ne sais pas » n'est pas « il n'y en a pas ».
|
|
|
|
|
|
|
|
|
|
Vécu : un « id » ambigu dans MON SQL faisait rendre une liste vide, et
|
|
|
|
|
l'outil annonçait tranquillement que la base n'avait pas été
|
|
|
|
|
neutralisée. Le back-office n'était pas testé, et rien ne le disait.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_a_row_saying_one_is_present(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.user_state("db", run_psql=lambda d, s: [["1"]]), "present"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_row_saying_zero_is_absent(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.user_state("db", run_psql=lambda d, s: [["0"]]), "absent"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_no_row_at_all_is_unknown(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.user_state("db", run_psql=lambda d, s: []), "unknown"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_psql_that_raises_is_unknown(self):
|
|
|
|
|
def explose(database, sql):
|
|
|
|
|
raise OSError("psql absent")
|
|
|
|
|
|
|
|
|
|
self.assertEqual(ui.user_state("db", run_psql=explose), "unknown")
|
|
|
|
|
|
|
|
|
|
def test_it_counts_rather_than_selects(self):
|
|
|
|
|
# Un COUNT rend TOUJOURS une ligne quand la requête aboutit : c'est
|
|
|
|
|
# ce qui sépare « aucun utilisateur » de « requête refusée ».
|
|
|
|
|
vu = {}
|
|
|
|
|
ui.user_state(
|
|
|
|
|
"db", run_psql=lambda d, s: vu.setdefault("sql", s) and []
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("count(", vu["sql"].lower())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTellingRealBreakageFromNoise(unittest.TestCase):
|
|
|
|
|
def test_a_bare_404_means_the_model_is_not_registered(self):
|
|
|
|
|
# Le message d'Odoo — « 404 Not Found: The requested URL... » — ne
|
|
|
|
|
# dit rien. Ce qu'il faut savoir : le module est installé dans la
|
|
|
|
|
# base et son code manque à cette version.
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
ui.model_is_unregistered({"name": "werkzeug.exceptions.NotFound"})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_an_access_error_is_not_that(self):
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
ui.model_is_unregistered({"name": "odoo.exceptions.AccessError"})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_missing_method_is_recognised(self):
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
ui._is_missing_method(
|
|
|
|
|
{
|
|
|
|
|
"name": "builtins.AttributeError",
|
|
|
|
|
"message": "object has no attribute 'load_views'",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_real_failure_is_not_mistaken_for_one(self):
|
|
|
|
|
# Retomber sur l'ancien nom de méthode après une vraie panne
|
|
|
|
|
# masquerait la panne derrière un « méthode inconnue » sans rapport.
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
ui._is_missing_method(
|
|
|
|
|
{"name": "odoo.exceptions.AccessError", "message": "refusé"}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNotEvaluatingWhatComesFromTheDatabase(unittest.TestCase):
|
|
|
|
|
"""Un domaine d'action est parfois du Python. On ne l'exécute pas."""
|
|
|
|
|
|
|
|
|
|
def test_a_literal_domain_is_used(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.literal("[('a','=',1)]", []), ([("a", "=", 1)], False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_an_expression_falls_back_and_SAYS_so(self):
|
|
|
|
|
# `uid`, `context_today` : seul le navigateur sait les évaluer. Le
|
|
|
|
|
# second membre du couple est ce qui permet de le dire au rapport
|
|
|
|
|
# au lieu de faire passer une approximation pour une mesure.
|
|
|
|
|
valeur, approx = ui.literal("[('user_id','=',uid)]", [])
|
|
|
|
|
self.assertEqual(valeur, [])
|
|
|
|
|
self.assertTrue(approx)
|
|
|
|
|
|
|
|
|
|
def test_an_empty_domain_is_not_an_approximation(self):
|
|
|
|
|
self.assertEqual(ui.literal("[]", []), ([], False))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatIsWorthOpening(unittest.TestCase):
|
|
|
|
|
def test_a_window_action_is_split(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.split_action("ir.actions.act_window,42"),
|
|
|
|
|
("ir.actions.act_window", 42),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_server_action_is_recognised_and_left_alone(self):
|
|
|
|
|
session = object()
|
|
|
|
|
app = {"id": 1, "name": "X"}
|
|
|
|
|
menu = {"id": 2, "name": "Y", "action": "ir.actions.server,9"}
|
|
|
|
|
# Une action serveur ÉCRIT : l'exécuter pour « voir si ça marche »
|
|
|
|
|
# ferait justement ce qu'un test ne doit pas faire.
|
|
|
|
|
result = ui.check_entry(session, app, menu)
|
|
|
|
|
self.assertEqual(result["kind"], "ir.actions.server")
|
|
|
|
|
self.assertIsNone(result["error"])
|
|
|
|
|
self.assertIsNone(result["model"])
|
|
|
|
|
|
|
|
|
|
def test_qweb_is_not_a_page_to_open(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
ui.view_pairs("list,qweb,form"), [[False, "list"], [False, "form"]]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_no_view_mode_still_gives_something(self):
|
|
|
|
|
self.assertTrue(ui.view_pairs(None))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLoadingTheFirstPage(unittest.TestCase):
|
|
|
|
|
"""L'appel qui charge les enregistrements, et sa forme exacte.
|
|
|
|
|
|
|
|
|
|
C'EST le défaut qui a coûté le plus cher : `web_search_read` a changé
|
|
|
|
|
de signature en 17 — `fields`, une liste, est devenu `specification`,
|
|
|
|
|
un dictionnaire. Mesuré sur une base 18.0 : seize applications sur
|
|
|
|
|
vingt-deux échouaient sur « unexpected keyword argument 'fields' »,
|
|
|
|
|
c'est-à-dire sur MON appel et non sur la base. `search_read` n'a pas
|
|
|
|
|
bougé depuis la 12.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def call(self, lst_field=None):
|
|
|
|
|
session = ui.Session("http://x")
|
|
|
|
|
vu = {}
|
|
|
|
|
|
|
|
|
|
def faux(model, method, args, kwargs=None):
|
|
|
|
|
vu.update(
|
|
|
|
|
model=model, method=method, args=args, kwargs=kwargs or {}
|
|
|
|
|
)
|
|
|
|
|
return [], None
|
|
|
|
|
|
|
|
|
|
session.call_kw = faux
|
|
|
|
|
session.first_page("sale.order", [], {}, 20, lst_field=lst_field)
|
|
|
|
|
return vu
|
|
|
|
|
|
|
|
|
|
def test_it_uses_the_call_that_never_changed(self):
|
|
|
|
|
self.assertEqual(self.call(["name"])["method"], "search_read")
|
|
|
|
|
|
|
|
|
|
def test_the_fields_go_where_search_read_expects_them(self):
|
|
|
|
|
vu = self.call(["name", "partner_id"])
|
|
|
|
|
self.assertEqual(vu["args"], [[], ["name", "partner_id"]])
|
|
|
|
|
self.assertEqual(vu["kwargs"]["limit"], 20)
|
|
|
|
|
|
|
|
|
|
def test_without_fields_it_still_loads_something(self):
|
|
|
|
|
self.assertEqual(self.call()["args"][1], ["display_name"])
|
|
|
|
|
|
|
|
|
|
def test_the_page_reads_the_columns_it_shows(self):
|
|
|
|
|
# Lire `display_name` seul ne prouverait presque rien : ce sont les
|
|
|
|
|
# colonnes de la liste qui font travailler l'ORM, et c'est là qu'un
|
|
|
|
|
# champ calculé cassé par la migration se manifeste.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(ui.check_entry)
|
|
|
|
|
self.assertIn("arch_fields(views)", source)
|
|
|
|
|
self.assertIn("lst_field=lst_read", source)
|
|
|
|
|
|
|
|
|
|
def test_a_field_the_model_lacks_is_never_requested(self):
|
|
|
|
|
# Le demander ferait échouer la lecture par NOTRE faute, et l'on
|
|
|
|
|
# perdrait le vrai signal derrière une erreur qu'on a causée.
|
|
|
|
|
source = __import__("inspect").getsource(ui.check_entry)
|
|
|
|
|
self.assertIn("if name in lst_known", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheViewsAreRenderedServerSide(unittest.TestCase):
|
|
|
|
|
def test_it_tries_the_modern_name_then_the_old_one(self):
|
|
|
|
|
# get_views depuis la 16, load_views avant, et la 18 n'a plus que le
|
|
|
|
|
# premier : une seule des deux ne couvrirait pas 12→18.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(ui.Session.views_of)
|
|
|
|
|
self.assertIn("get_views", source)
|
|
|
|
|
self.assertIn("load_views", source)
|
|
|
|
|
self.assertLess(source.index("get_views"), source.index("load_views"))
|
|
|
|
|
|
|
|
|
|
def test_the_resolved_name_is_remembered(self):
|
|
|
|
|
# Chercher à chaque application doublerait le nombre de requêtes.
|
|
|
|
|
session = ui.Session("http://x")
|
|
|
|
|
appels = []
|
|
|
|
|
|
|
|
|
|
def faux(model, method, args, kwargs=None):
|
|
|
|
|
appels.append(method)
|
|
|
|
|
if method == "get_views":
|
|
|
|
|
return None, {
|
|
|
|
|
"name": "builtins.AttributeError",
|
|
|
|
|
"message": "has no attribute",
|
|
|
|
|
}
|
|
|
|
|
return {"views": {}}, None
|
|
|
|
|
|
|
|
|
|
session.call_kw = faux
|
|
|
|
|
session.views_of("m", [[False, "list"]])
|
|
|
|
|
session.views_of("m", [[False, "list"]])
|
|
|
|
|
self.assertEqual(appels, ["get_views", "load_views", "load_views"])
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-19 03:21:25 -04:00
|
|
|
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:"),
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
source.index("run_tool("),
|
[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
2026-08-19 03:21:25 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
[FIX] smoke: judge the back office AFTER the repair, and say why it failed
No timeout was hit and no test was skipped. The back office failed for the
same reason the site did: the COW copy of website.submenu breaks
/web/login exactly as it breaks the public pages — both go through the
same layout. The pass simply ran BEFORE the reset, and unlike the URLs it
was never looked at again. It is now, on the same second server.
« Session expired » described the consequence and hid the cause. The login
page returned 500 and nothing said so, so one looks at the password. The
status of that page, and a missing CSRF token, are now named.
Diagnosing this, I started Odoo 18 on a 17 database and got 500 on all
thirty-seven URLs and on /web/login: a report that says the site is
entirely broken when nothing is. database_cleanup already refused that;
the smoke tools now share the same guard rather than reimplementing it.
--- FR ---
[FIX] smoke : juger le back-office APRÈS la réparation, et dire pourquoi
Aucun délai n'a été atteint et aucun test n'a été sauté. Le back-office a
échoué pour la raison même qui cassait le site : la copie COW de
website.submenu casse /web/login comme elle casse les pages publiques —
les deux passent par le même gabarit. La passe tournait avant la
réinitialisation et, contrairement aux URL, n'était jamais revue.
« Session expired » décrivait la conséquence et cachait la cause : la page
de connexion rendait 500 sans que rien ne le dise, et l'on cherchait du
côté du mot de passe.
En diagnostiquant, j'ai lancé un Odoo 18 sur une base 17 : 500 partout, un
rapport qui déclare le site entièrement cassé quand rien ne l'est.
database_cleanup refusait déjà cela ; les outils de fumée partagent
désormais sa garde au lieu d'en écrire une seconde.
Assisted-by: Claude Opus 5
2026-08-19 04:53:43 -04:00
|
|
|
class TestSayingWHYTheLoginFailed(unittest.TestCase):
|
|
|
|
|
"""« Session expired » décrit la conséquence et cache la cause.
|
|
|
|
|
|
|
|
|
|
Vécu : la passe back-office rapportait « Connexion impossible en tant
|
|
|
|
|
que test : SessionExpiredException », et l'on a cherché du côté du mot
|
|
|
|
|
de passe pendant que la vraie cause était sous les yeux — /web/login
|
|
|
|
|
rendait 500. Une copie COW de `website.submenu` casse la page de
|
|
|
|
|
connexion comme elle casse le site : les deux passent par le même
|
|
|
|
|
gabarit.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
def session(self, status, body):
|
|
|
|
|
session = ui.Session("http://x")
|
|
|
|
|
session.open = lambda path, data=None, headers=None: (status, body)
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
def test_a_broken_login_page_is_named_as_such(self):
|
|
|
|
|
ok, raison = self.session(500, "<html>oups</html>").log_in(
|
|
|
|
|
"db", "test", "test"
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(ok)
|
|
|
|
|
self.assertIn("login page itself failed", raison)
|
|
|
|
|
self.assertIn("500", raison)
|
|
|
|
|
|
|
|
|
|
def test_it_does_not_blame_the_password(self):
|
|
|
|
|
_ok, raison = self.session(500, "<html>oups</html>").log_in(
|
|
|
|
|
"db", "test", "test"
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("password", raison.lower())
|
|
|
|
|
|
|
|
|
|
def test_a_page_without_a_token_is_named_too(self):
|
|
|
|
|
# Sans jeton, Odoo refuse le POST et rend la page de connexion avec
|
|
|
|
|
# un statut 200 : on croirait à un mot de passe refusé.
|
|
|
|
|
ok, raison = self.session(200, "<html>rien</html>").log_in(
|
|
|
|
|
"db", "test", "test"
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(ok)
|
|
|
|
|
self.assertIn("CSRF", raison)
|
|
|
|
|
|
|
|
|
|
def test_a_dead_server_is_still_distinguished(self):
|
|
|
|
|
ok, raison = self.session(0, "").log_in("db", "test", "test")
|
|
|
|
|
self.assertFalse(ok)
|
|
|
|
|
self.assertIn("did not serve", raison)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheBackOfficeIsJudgedAfterTheRepair(unittest.TestCase):
|
|
|
|
|
"""La passe interne tournait AVANT la réinitialisation.
|
|
|
|
|
|
|
|
|
|
Elle rapportait donc « connexion impossible » sur une base que la
|
|
|
|
|
réparation remettait d'aplomb quelques secondes plus tard — et rien ne
|
|
|
|
|
corrigeait le rapport. Les URL, elles, étaient déjà revérifiées.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
def test_a_failed_pass_deserves_a_second_look(self):
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertTrue(public.internal_needs_retry({"failures": [{"x": 1}]}))
|
|
|
|
|
|
|
|
|
|
def test_a_SKIPPED_pass_too(self):
|
|
|
|
|
# « connexion impossible » est justement le symptôme d'un site
|
|
|
|
|
# cassé, c'est-à-dire de ce qu'on vient de réparer.
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertTrue(public.internal_needs_retry({"skipped": "raison"}))
|
|
|
|
|
|
|
|
|
|
def test_a_healthy_pass_is_not_redone(self):
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
public.internal_needs_retry({"results": [1], "failures": []})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_no_pass_at_all_is_not_redone(self):
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertFalse(public.internal_needs_retry(None))
|
|
|
|
|
|
|
|
|
|
def test_the_retry_happens_while_the_second_server_is_up(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(public.recheck_after_reset)
|
|
|
|
|
self.assertLess(
|
|
|
|
|
source.index("internal_needs_retry"),
|
|
|
|
|
source.index("stop_server(server)"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_report_says_it_was_a_second_look(self):
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
public.render_internal(
|
|
|
|
|
{"skipped": "raison", "loud": True, "retried": True}
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("after the reset", out.getvalue())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
|
|
|
|
|
"""Mesuré en diagnostiquant ce même incident, et je m'y suis pris.
|
|
|
|
|
|
|
|
|
|
Un checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
|
|
|
|
|
trente-sept URL ET sur /web/login. On conclut à un site entièrement
|
|
|
|
|
cassé alors que rien ne l'est — et Odoo écrit en chemin.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_smoke_tool_has_the_guard(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(public.run)
|
|
|
|
|
self.assertIn("require_matching_version", source)
|
|
|
|
|
self.assertLess(
|
|
|
|
|
source.index("require_matching_version"),
|
|
|
|
|
source.index("start_server("),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_internal_tool_has_it_too(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(ui.main)
|
|
|
|
|
self.assertIn("require_matching_version", source)
|
|
|
|
|
|
|
|
|
|
def test_the_guard_is_not_reimplemented(self):
|
|
|
|
|
# Deux implémentations divergeraient, et une garde qui diverge ne
|
|
|
|
|
# garde rien.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(public.require_matching_version)
|
|
|
|
|
self.assertIn("from database_cleanup import", source)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: open /my as the test user, and blame the right request
The portal is a third rendering, neither the public site nor the back
office: QWeb frontend with counters that each query their own model. The
sitemap does not list /my — the page needs a session — and no RPC call
goes through it. A migration can break it with nothing else noticing.
Measured on a real database: /my answered 500 on a user field a module no
longer defines, while every public page and sixteen apps were fine.
Two guards the run proved necessary. Requested without a session, /my
redirects to the login form and answers 200: counting that as a success
would announce a healthy portal never seen. And the production error page
shows no traceback, so the reason comes from the server log — attributed
by PATH, because the portal is checked first and taking the last trace
blamed it for an app's failure. A wrong cause costs more than none.
--- FR ---
[ADD] migration : ouvrir /my avec l'utilisateur test, et accuser la bonne requête
Le portail est un troisième rendu, ni le site public ni le back-office :
du QWeb frontend, avec ses compteurs qui interrogent chacun leur modèle.
Le sitemap ne liste pas /my — la page demande une session — et aucun appel
RPC n'y passe. Une migration peut le casser sans que rien ne le dise.
Mesuré sur une vraie base : /my rendait 500 sur un champ utilisateur qu'un
module ne définit plus, alors que les pages publiques et seize
applications allaient bien.
Deux garde-fous que l'essai a rendus nécessaires. Sans session, /my
redirige vers le formulaire de connexion et rend 200 : le compter pour une
réussite annoncerait un portail jamais vu. Et la page d'erreur de
production ne montre aucune trace : la raison vient du journal, attribuée
par CHEMIN — le portail est interrogé en premier, et prendre la dernière
trace l'accusait de la panne d'une application.
Assisted-by: Claude Opus 5
2026-08-17 22:48:40 -04:00
|
|
|
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
|
|
|
|
|
modèle. Le sitemap ne le liste pas — la page demande une session — et
|
|
|
|
|
aucun appel RPC ne passe par là. Une migration peut donc le casser sans
|
|
|
|
|
que rien ailleurs ne le dise.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
def session(self, status, body, url=None):
|
|
|
|
|
session = ui.Session("http://x")
|
|
|
|
|
session.last_url = url or ("http://x/my")
|
|
|
|
|
|
|
|
|
|
def faux(path, data=None, headers=None):
|
|
|
|
|
return status, body
|
|
|
|
|
|
|
|
|
|
session.open = faux
|
|
|
|
|
return session
|
|
|
|
|
|
|
|
|
|
def test_a_page_that_answers_is_a_success(self):
|
|
|
|
|
item = ui.check_portal(
|
|
|
|
|
self.session(200, "<html>Mon compte</html>"), "/my"
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(item["error"])
|
|
|
|
|
self.assertEqual(item["status"], 200)
|
|
|
|
|
|
|
|
|
|
def test_a_five_hundred_is_a_failure(self):
|
|
|
|
|
item = ui.check_portal(self.session(500, "<html>oups</html>"), "/my")
|
|
|
|
|
self.assertEqual(item["stage"], ui.PORTAL_KIND)
|
|
|
|
|
self.assertIn("500", item["error"]["name"])
|
|
|
|
|
|
|
|
|
|
def test_a_dead_connection_is_a_failure(self):
|
|
|
|
|
item = ui.check_portal(self.session(0, "connexion refusée"), "/my")
|
|
|
|
|
self.assertIsNotNone(item["error"])
|
|
|
|
|
|
|
|
|
|
def test_a_login_form_served_with_200_is_NOT_a_success(self):
|
|
|
|
|
# MESURÉ : /my demandé sans session redirige vers
|
|
|
|
|
# /web/login?redirect=/my et rend 200. Compter cela comme une
|
|
|
|
|
# réussite serait le mensonge le plus coûteux du lot — on
|
|
|
|
|
# annoncerait un portail sain sans l'avoir jamais vu.
|
|
|
|
|
page = '<form><input name="login"/><input name="password"/></form>'
|
|
|
|
|
item = ui.check_portal(self.session(200, page), "/my")
|
|
|
|
|
self.assertIsNotNone(item["error"])
|
|
|
|
|
|
|
|
|
|
def test_a_redirect_to_the_login_is_caught_even_without_the_form(self):
|
|
|
|
|
item = ui.check_portal(
|
|
|
|
|
self.session(
|
|
|
|
|
200, "<html>rien</html>", url="http://x/web/login?redirect=/my"
|
|
|
|
|
),
|
|
|
|
|
"/my",
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNotNone(item["error"])
|
|
|
|
|
|
|
|
|
|
def test_the_word_login_alone_does_not_condemn_a_page(self):
|
|
|
|
|
# Le mot est partout dans le portail. N'exiger que lui rejetterait
|
|
|
|
|
# des pages parfaitement saines.
|
|
|
|
|
page = "<html>Votre login est test. Bienvenue.</html>"
|
|
|
|
|
item = ui.check_portal(self.session(200, page), "/my")
|
|
|
|
|
self.assertIsNone(item["error"])
|
|
|
|
|
|
|
|
|
|
def test_the_error_page_is_read_without_its_stylesheet(self):
|
|
|
|
|
# MESURÉ : « Internal Server Error html { font-size: 14px; } Home
|
|
|
|
|
# Back 500 » — du CSS présenté comme un diagnostic.
|
|
|
|
|
page = (
|
|
|
|
|
"<html><style>html { font-size: 14px; }</style>"
|
|
|
|
|
"<body>500: Internal Server Error</body></html>"
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("font-size", ui.error_from_page(page))
|
|
|
|
|
|
|
|
|
|
def test_it_is_checked_before_the_apps(self):
|
|
|
|
|
# L'ordre n'est pas indifférent : si la session est perdue, tout le
|
|
|
|
|
# reste échouera aussi, et l'on veut la cause en tête du rapport.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(ui.crawl)
|
|
|
|
|
self.assertLess(
|
|
|
|
|
source.index("check_portal"), source.index("check_entry")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_asking_for_no_portal_path_checks_none(self):
|
|
|
|
|
session = self.session(200, "ok")
|
|
|
|
|
session.call_kw = lambda *a, **kw: ([], None)
|
|
|
|
|
lst_result, _ = ui.crawl(session, lst_portal=[])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[x for x in lst_result if x["kind"] == ui.PORTAL_KIND], []
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_default_path_is_my(self):
|
|
|
|
|
self.assertEqual(ui.DEFAULT_PORTAL_PATHS, ("/my",))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBlamingTheRightRequest(unittest.TestCase):
|
|
|
|
|
"""Une cause fausse coûte plus cher qu'une cause absente.
|
|
|
|
|
|
|
|
|
|
Vécu : le portail est interrogé en PREMIER, donc sa trace est la
|
|
|
|
|
première du journal. En prenant simplement la dernière, on accusait /my
|
|
|
|
|
d'une panne appartenant à une application testée bien après — et l'on
|
|
|
|
|
aurait cherché des heures du mauvais côté, avec confiance.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
JOURNAL = [
|
|
|
|
|
"AttributeError: 'res.users' object has no attribute 'portal_360'",
|
|
|
|
|
'... werkzeug: "GET /my HTTP/1.1" 500 - 8 0.004 0.029',
|
|
|
|
|
"ValueError: Invalid field project.project.is_project_br",
|
|
|
|
|
'... werkzeug: "POST /web/dataset/call_kw HTTP/1.1" 200 - 3 0.01',
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_the_trace_of_that_very_request_is_taken(self):
|
|
|
|
|
self.assertIn("portal_360", ui.reason_for_path(self.JOURNAL, "/my"))
|
|
|
|
|
|
|
|
|
|
def test_the_trace_of_another_request_is_NOT(self):
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
"is_project_br", ui.reason_for_path(self.JOURNAL, "/my") or ""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_healthy_answer_wipes_the_slate(self):
|
|
|
|
|
journal = [
|
|
|
|
|
"ValueError: sans rapport",
|
|
|
|
|
'... werkzeug: "GET /my HTTP/1.1" 200 - 8 0.004',
|
|
|
|
|
]
|
|
|
|
|
self.assertIsNone(ui.reason_for_path(journal, "/my"))
|
|
|
|
|
|
|
|
|
|
def test_a_redirected_path_still_matches(self):
|
|
|
|
|
# /my renvoie vers /my/home : la trace est loggée sous le second.
|
|
|
|
|
journal = [
|
|
|
|
|
"KeyError: compteur",
|
|
|
|
|
'... werkzeug: "GET /my/home HTTP/1.1" 500 - 8 0.004',
|
|
|
|
|
]
|
|
|
|
|
self.assertIn("KeyError", ui.reason_for_path(journal, "/my"))
|
|
|
|
|
|
|
|
|
|
def test_a_similar_path_is_not_confused_with_it(self):
|
|
|
|
|
journal = [
|
|
|
|
|
"KeyError: ailleurs",
|
|
|
|
|
'... werkzeug: "GET /myaccount HTTP/1.1" 500 - 8 0.004',
|
|
|
|
|
]
|
|
|
|
|
self.assertIsNone(ui.reason_for_path(journal, "/my"))
|
|
|
|
|
|
|
|
|
|
def test_an_rpc_message_is_never_overwritten(self):
|
|
|
|
|
# Un appel RPC nomme précisément SON erreur ; le journal est un flux
|
|
|
|
|
# partagé. Écraser le premier par le second serait perdre la seule
|
|
|
|
|
# information sûre.
|
|
|
|
|
item = ui.blank_entry("Ventes", "Devis", kind="ir.actions.act_window")
|
|
|
|
|
item["error"] = {"name": "KeyError", "message": "précis"}
|
|
|
|
|
ui.attach_log_reason(
|
|
|
|
|
[item], ["ValueError: du journal", '"GET /my" 500']
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("log", item["error"])
|
|
|
|
|
|
|
|
|
|
def test_the_server_is_asked_to_log_its_requests(self):
|
|
|
|
|
# Sans les lignes d'accès, aucune trace ne peut être rattachée à un
|
|
|
|
|
# chemin : elles coûtent deux lignes par requête.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertIn("werkzeug:INFO", inspect.getsource(public.start_server))
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: open every app as the neutralization test user
The public smoke test opens what a visitor reaches. It says nothing about
the back office, which is where a migration does most of its damage: a
field dropped from a model but still named in a form, a module installed
in the database whose code no longer ships with the target version. None
of it stops the module loading — it stops the day someone opens the app.
Neutralizing installs a `test` / `test` login carrying the SYSTEM user's
groups, and it survives the module's uninstall. So the tool checks for
that user rather than trusting a flag, and it rides the server the public
pass already started: booting Odoo is what costs minutes, not requests.
Measured on a real 18.0 database of 25 apps, which found four defects of
mine: web_search_read changed signature in 17, a bare 404 hides an
unregistered model, embedded sub-views are not the parent's fields, and
an empty psql result meant two different things.
--- FR ---
[ADD] migration : ouvrir chaque application avec l'utilisateur test
Le test de fumée public ouvre ce qu'un visiteur atteint. Il ne dit rien du
back-office, où une migration fait pourtant l'essentiel de ses dégâts : un
champ retiré du modèle mais toujours nommé dans un formulaire, un module
installé en base dont le code n'accompagne plus la version cible. Rien de
cela n'arrête le chargement ; cela arrête le jour où l'on ouvre l'appli.
La neutralisation pose un compte `test` / `test` portant les groupes du
superutilisateur, et il survit à la désinstallation du module. L'outil
vérifie donc cet utilisateur plutôt qu'un drapeau, et réutilise le serveur
déjà démarré : c'est le démarrage qui coûte, pas les requêtes.
Mesuré sur une vraie base 18.0 de 25 applications, ce qui a révélé quatre
défauts de mon fait.
Assisted-by: Claude Opus 5
2026-08-17 22:16:40 -04:00
|
|
|
class TestTheReport(unittest.TestCase):
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
def entry(self, **kw):
|
|
|
|
|
item = {
|
|
|
|
|
"app": "Sales",
|
|
|
|
|
"menu": "Quotations",
|
|
|
|
|
"model": "sale.order",
|
|
|
|
|
"kind": "ir.actions.act_window",
|
|
|
|
|
"error": None,
|
|
|
|
|
"stage": None,
|
|
|
|
|
"domain_ignored": False,
|
|
|
|
|
"unknown_fields": [],
|
|
|
|
|
"fields_read": 3,
|
|
|
|
|
}
|
|
|
|
|
item.update(kw)
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
def test_all_clean_says_so(self):
|
|
|
|
|
text = ui.render([self.entry()], [])
|
|
|
|
|
self.assertIn("1/1", text)
|
|
|
|
|
self.assertIn("opened its first page", text)
|
|
|
|
|
|
|
|
|
|
def test_a_registry_failure_explains_itself(self):
|
|
|
|
|
# Le 404 nu est illisible : le rapport doit dire ce qu'il signifie.
|
|
|
|
|
item = self.entry(
|
|
|
|
|
stage="registry",
|
|
|
|
|
error={"name": "werkzeug.exceptions.NotFound", "message": "404"},
|
|
|
|
|
)
|
|
|
|
|
text = ui.render([item], [item])
|
|
|
|
|
self.assertIn("addons path", text)
|
|
|
|
|
self.assertNotIn("The requested URL", text)
|
|
|
|
|
|
|
|
|
|
def test_ghost_fields_are_reported_without_being_a_failure(self):
|
|
|
|
|
# Une vue qui nomme un champ disparu n'empêche pas toujours la page
|
|
|
|
|
# de s'ouvrir. Le taire laisserait passer la cause d'une panne à
|
|
|
|
|
# venir ; le compter comme un échec ferait crier au loup.
|
|
|
|
|
item = self.entry(unknown_fields=["grant_offer_id"])
|
|
|
|
|
text = ui.render([item], [])
|
|
|
|
|
self.assertIn("grant_offer_id", text)
|
|
|
|
|
self.assertIn("1/1", text)
|
|
|
|
|
|
|
|
|
|
def test_an_approximate_domain_is_admitted(self):
|
|
|
|
|
text = ui.render([self.entry(domain_ignored=True)], [])
|
|
|
|
|
self.assertIn("empty domain", text)
|
|
|
|
|
|
|
|
|
|
def test_the_report_says_the_public_test_cannot_see_this(self):
|
|
|
|
|
item = self.entry(
|
|
|
|
|
stage="records", error={"name": "KeyError", "message": "x"}
|
|
|
|
|
)
|
|
|
|
|
text = ui.render([item], [item])
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"back-office", text.lower().replace("back office", "back-office")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestItRidesTheServerAlreadyRunning(unittest.TestCase):
|
|
|
|
|
"""Le démarrage d'Odoo coûte des minutes ; les requêtes, non."""
|
|
|
|
|
|
|
|
|
|
def test_the_public_tool_runs_it_in_the_same_session(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(public.run)
|
|
|
|
|
debut = source.index("check_urls(")
|
|
|
|
|
arret = source.index("stop_server(server)")
|
|
|
|
|
self.assertLess(debut, source.index("internal_phase("))
|
|
|
|
|
self.assertLess(source.index("internal_phase("), arret)
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_state_is_not_reported_as_healthy(self):
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
rapport = public.internal_phase("http://x", "db", enabled=True)
|
|
|
|
|
self.assertIsNotNone(rapport)
|
|
|
|
|
|
|
|
|
|
def test_disabling_it_returns_nothing(self):
|
|
|
|
|
import smoke_public_url as public
|
|
|
|
|
|
|
|
|
|
self.assertIsNone(
|
|
|
|
|
public.internal_phase("http://x", "db", enabled=False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|