[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
This commit is contained in:
Mathieu Benoit 2026-08-17 22:16:40 -04:00
parent 95a6ba558e
commit d8794f0e10
5 changed files with 1502 additions and 8 deletions

View file

@ -0,0 +1,765 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Log in as `test` and open the first page of every Odoo app.
Why this exists
---------------
The public smoke test opens what a visitor can reach. 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 view, a widget renamed between
two versions, a stored compute that no longer resolves. None of it stops the
module loading — it stops the day someone opens the app.
Why the `test` user
-------------------
Neutralizing a database installs `user_test`, which copies the SYSTEM user's
groups onto a `test` / `test` login (see its `post_init_hook`). The module is
uninstalled right after, but the user survives — its uninstall hook only
warns. So a neutralized database, and only a neutralized one, can be browsed
without knowing anyone's real password. The tool checks for that user rather
than trusting a flag: a resumed migration may have skipped neutralization,
and a promise in a progression file is not a login.
What is actually exercised
--------------------------
For each app — a root menu — its first page, meaning the first menu below it
that carries an action, which is what the web client opens on a click. Two
calls per app, both server-side:
- ``get_views`` (12→15: ``load_views``) renders EVERY view arch of the
action. This is where a migration's damage surfaces, and it surfaces as an
exception rather than an empty screen.
- ``web_search_read`` (12: ``search_read``) loads the first page of records.
A view can render on an empty model and still explode on real data.
Exit codes: 0 every app answered, 1 some failed, 2 the tool failed.
"""
import argparse
import ast
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
DEFAULT_LOGIN = "test"
DEFAULT_PASSWORD = "test"
DEFAULT_RECORD_LIMIT = 20
# Deux façons d'écrire le même champ caché selon la version du gabarit :
# l'ordre des attributs n'est pas garanti, et un seul motif en manquait la
# moitié — la connexion échouait alors sans rien dire d'utile.
RE_CSRF = re.compile(
r"""name=["']csrf_token["']\s+value=["']([^"']+)["']"""
r"""|value=["']([^"']+)["']\s+name=["']csrf_token["']"""
)
RE_DB_FIELD = re.compile(r"""name=["']db["']""")
def user_state(database, login=DEFAULT_LOGIN, run_psql=None):
"""« present », « absent » ou « unknown ». La nuance n'est pas cosmétique.
On INTERROGE plutôt que de croire un drapeau : une migration reprise a
pu sauter la neutralisation, et `state_1_neutralize_database` dirait
quand même « fait ».
Mais `run_psql` rend une liste vide DANS LES DEUX CAS — aucune ligne, ou
requête refusée. Mesuré ici même : un « id » ambigu passait pour « pas
d'utilisateur test », et l'outil annonçait tranquillement que la base
n'avait pas été neutralisée. Un COUNT distingue les deux : il rend
toujours une ligne quand la requête aboutit.
"""
if run_psql is None:
from smoke_public_url import run_psql
sql = (
"SELECT count(*) FROM res_users" f" WHERE login = '{login}' AND active"
)
try:
rows = run_psql(database, sql)
except Exception:
return "unknown"
if not rows:
return "unknown"
try:
return "present" if int(rows[0][0]) else "absent"
except (TypeError, ValueError, IndexError):
return "unknown"
def user_exists(database, login=DEFAULT_LOGIN, run_psql=None):
"""Raccourci : seul « present » vaut oui."""
return user_state(database, login, run_psql=run_psql) == "present"
class Session:
"""Un client HTTP qui garde son cookie de session.
`urllib` sans gestionnaire de cookies renvoie chaque appel comme un
inconnu : on se connecte, puis on interroge en anonyme sans qu'aucune
erreur ne le signale — juste des résultats vides.
"""
def __init__(self, base_url, timeout=60):
import http.cookiejar
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.jar)
)
self.uid = None
# Le nom des méthodes a changé en cours de route (get_views en 16,
# load_views avant). On retient celui qui a répondu : chercher à
# chaque appel doublerait le nombre de requêtes.
self._views_method = None
self._read_method = None
def open(self, path, data=None, headers=None):
url = self.base_url + path
request = urllib.request.Request(url, data=data, headers=headers or {})
try:
with self.opener.open(request, timeout=self.timeout) as answer:
return answer.getcode(), answer.read().decode(
"utf-8", errors="replace"
)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except Exception as exc:
return 0, str(exc)
def rpc(self, path, params):
"""Un appel JSON-RPC. Rend (résultat, erreur) — jamais une exception.
Une erreur Odoo arrive avec le statut 200 et un objet `error` dans
le corps : la traiter comme un succès ferait passer une vue cassée
pour une vue vide.
"""
payload = json.dumps(
{
"jsonrpc": "2.0",
"method": "call",
"params": params,
"id": 1,
}
).encode("utf-8")
status, body = self.open(
path, data=payload, headers={"Content-Type": "application/json"}
)
if not status:
return None, {"name": "transport", "message": body[:200]}
try:
answer = json.loads(body)
except ValueError:
return None, {
"name": f"HTTP {status}",
"message": body.strip()[:200],
}
if "error" in answer:
error = answer["error"] or {}
data = error.get("data") or {}
return None, {
"name": data.get("name") or error.get("message") or "error",
"message": (
data.get("message") or error.get("message") or ""
).strip(),
"debug": data.get("debug") or "",
}
return answer.get("result"), None
def call_kw(self, model, method, args, kwargs=None):
return self.rpc(
"/web/dataset/call_kw",
{
"model": model,
"method": method,
"args": args,
"kwargs": kwargs or {},
},
)
def log_in(self, database, login, password):
"""Se connecter par le formulaire, comme le ferait un navigateur.
Le jeton CSRF n'est pas une formalité : sans lui Odoo rend la page
de connexion avec un statut 200, et l'on croirait être entré.
"""
status, body = self.open("/web/login")
if not status:
return False, t("The server did not serve the login page.")
match = RE_CSRF.search(body)
fields = {
"login": login,
"password": password,
"redirect": "",
}
if match:
fields["csrf_token"] = match.group(1) or match.group(2)
# Le champ `db` n'existe que si le serveur en propose plusieurs.
# L'envoyer toujours n'est pas anodin : certaines versions le
# refusent quand la liste des bases est masquée.
if RE_DB_FIELD.search(body):
fields["db"] = database
self.open(
"/web/login",
data=urllib.parse.urlencode(fields).encode("utf-8"),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# On ne LIT pas la redirection pour juger : on demande au serveur
# qui il croit que nous sommes.
info, error = self.rpc("/web/session/get_session_info", {})
if error:
return False, f"{error['name']} : {error['message']}"[:200]
uid = (info or {}).get("uid")
if not uid:
return False, t("Wrong login or password for user")
self.uid = uid
return True, ""
def views_of(self, model, lst_view, context=None):
"""Faire rendre les arch côté serveur. C'est LE test.
`get_views` depuis la 16, `load_views` avant, et la 18 n'a plus que
le premier. On essaie le moderne, on retombe sur l'ancien.
"""
kwargs = {"views": lst_view, "options": {}, "context": context or {}}
lst_try = (
[self._views_method]
if self._views_method
else ["get_views", "load_views"]
)
last = None
for method in lst_try:
result, error = self.call_kw(model, method, [], dict(kwargs))
if error is None:
self._views_method = method
return result, None
if not _is_missing_method(error):
return None, error
last = error
return None, last
def first_page(self, model, domain, context, limit, lst_field=None):
"""Charger la première page d'enregistrements.
`search_read` et non `web_search_read` : ce dernier a changé de
signature en cours de route — `fields` (une liste) est devenu
`specification` (un dictionnaire) en 17. Mesuré sur une base 18 :
seize applications sur vingt-cinq échouaient sur
« unexpected keyword argument 'fields' », c'est-à-dire sur MON
appel, pas sur la base. `search_read` n'a pas bougé depuis la 12.
Une vue peut se rendre sur un modèle vide et exploser sur de
vraies données : un champ calculé qui ne résout plus ne se voit
qu'une fois qu'il y a quelque chose à calculer.
"""
return self.call_kw(
model,
"search_read",
[domain, lst_field or ["display_name"]],
{"limit": limit, "context": context or {}},
)
def known_fields(self, model, context=None):
"""Les champs que le modèle porte VRAIMENT, d'après le serveur."""
result, error = self.call_kw(
model, "fields_get", [[], ["type"]], {"context": context or {}}
)
if error:
return None, error
return sorted(result or {}), None
def arch_fields(views):
"""Les champs que la première page affiche, et RIEN de plus.
Analyse XML et non expression régulière, pour une raison mesurée : une
vue formulaire embarque les sous-vues de ses one2many, et un
`<field name="product_id"/>` qui s'y trouve appartient à la LIGNE de
facture, pas à la facture. Le motif à plat les ramassait aussi et
rapportait neuf applications « nommant un champ que le modèle n'a
plus » — toutes fausses. On ne descend donc jamais dans un `<field>`.
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 une migration se manifeste.
"""
import xml.etree.ElementTree as ET
lst_arch = []
def descendre(noeud):
if isinstance(noeud, dict):
arch = noeud.get("arch")
if isinstance(arch, str):
lst_arch.append(arch)
for valeur in noeud.values():
descendre(valeur)
elif isinstance(noeud, list):
for valeur in noeud:
descendre(valeur)
descendre(views)
lst_name = []
def parcourir(element):
for enfant in element:
if enfant.tag == "field":
nom = enfant.get("name")
# Un champ pointé vise un sous-modèle : le demander au
# modèle principal serait une erreur de NOTRE fait.
if nom and "." not in nom and nom not in lst_name:
lst_name.append(nom)
# On s'arrête là : ce qui est SOUS un champ appartient au
# modèle de ce champ, pas au nôtre.
continue
parcourir(enfant)
for arch in lst_arch:
try:
racine = ET.fromstring(arch)
except ET.ParseError:
# Un arch illisible n'est pas une liste de champs vide : c'est
# une inconnue. La taire vaut mieux que d'inventer.
continue
if racine.tag == "field":
nom = racine.get("name")
if nom and "." not in nom and nom not in lst_name:
lst_name.append(nom)
else:
parcourir(racine)
return lst_name
def model_is_unregistered(error):
"""L'erreur dit-elle « ce modèle n'existe pas dans ce registre » ?
Odoo rend un 404 nu quand `call_kw` vise un modèle absent du registre :
« 404 Not Found: The requested URL was not found ». C'est illisible, et
pourtant c'est la trouvaille la plus nette d'une migration — le module
est installé dans la base, mais son code n'est plus dans l'addons path
de la version cible. Mesuré : cinq applications sur vingt-cinq.
"""
return "notfound" in (error.get("name") or "").lower().replace(".", "")
def _is_missing_method(error):
"""L'erreur dit-elle « cette méthode n'existe pas » ?
Il faut la distinguer d'une vraie panne : retomber sur l'ancien nom
après une AccessError masquerait l'AccessError derrière un
« méthode inconnue » qui n'a rien à voir.
"""
texte = f"{error.get('name', '')} {error.get('message', '')}".lower()
return (
"not exist" in texte
or "unknown method" in texte
or "attributeerror" in texte
or "has no attribute" in texte
)
def menu_rows(session):
"""Les menus que CET utilisateur voit, dans l'ordre où il les voit.
Par RPC et non par SQL : `ir.ui.menu` filtre selon les droits et rend
les noms traduits. Lire la table donnerait des menus inaccessibles et,
depuis la 16, un nom en jsonb qu'il faudrait décoder à la main.
"""
return session.call_kw(
"ir.ui.menu",
"search_read",
[[], ["id", "name", "parent_id", "sequence", "action"]],
{"context": {"lang": "en_US"}},
)
def apps(lst_menu):
"""(application, menu de sa première page) pour chaque application.
La première page d'une application est ce que le client web ouvre au
clic : le premier menu, en descendant, qui porte une action. Une
application qui n'en a aucune n'est pas une erreur — elle n'a rien à
ouvrir, et le dire évite de la chercher.
"""
par_parent = {}
for menu in lst_menu:
parent = menu.get("parent_id")
cle = parent[0] if isinstance(parent, (list, tuple)) else None
par_parent.setdefault(cle, []).append(menu)
for lst in par_parent.values():
lst.sort(key=lambda m: (m.get("sequence") or 0, m.get("id") or 0))
def descendre(menu, vus):
if menu["id"] in vus:
return None
vus.add(menu["id"])
if menu.get("action"):
return menu
for enfant in par_parent.get(menu["id"], []):
trouve = descendre(enfant, vus)
if trouve:
return trouve
return None
resultat = []
for racine in par_parent.get(None, []):
resultat.append((racine, descendre(racine, set())))
return resultat
def actionable(lst_menu):
"""TOUS les menus portant une action, pour le balayage complet."""
return [menu for menu in lst_menu if menu.get("action")]
def split_action(reference):
"""« ir.actions.act_window,42 » -> (« ir.actions.act_window », 42)."""
if not reference or "," not in str(reference):
return None, None
model, _sep, ident = str(reference).partition(",")
try:
return model.strip(), int(ident)
except ValueError:
return model.strip(), None
def literal(value, fallback):
"""Un domaine ou un contexte, s'il est LITTÉRAL. Sinon le repli.
Ils contiennent parfois des expressions Python — `uid`, `context_today`
— que seul le client sait évaluer. Les évaluer ici serait exécuter du
code venu de la base ; les refuser tout court retirerait la moitié des
applications du balayage. On teste donc avec un domaine vide, et on le
DIT dans le rapport.
"""
if not value or value in ("[]", "{}"):
return fallback, False
try:
return ast.literal_eval(value), False
except (ValueError, SyntaxError):
return fallback, True
def view_pairs(view_mode):
"""« list,form » -> [[False, « list »], [False, « form »]].
`qweb` est écarté : son rendu demande un enregistrement précis et un
contexte de rapport, pas une ouverture de menu.
"""
lst = []
for mode in (view_mode or "list,form").split(","):
mode = mode.strip()
if mode and mode != "qweb":
lst.append([False, mode])
return lst or [[False, "form"]]
def check_entry(session, app, menu, limit=DEFAULT_RECORD_LIMIT):
"""Ouvrir une entrée de menu. Rend un dict décrivant ce qui s'est passé."""
resultat = {
"app": app["name"],
"menu": menu["name"],
"action": menu.get("action"),
"model": None,
"kind": None,
"error": None,
"stage": None,
"domain_ignored": False,
"unknown_fields": [],
"fields_read": 0,
}
model_action, ident = split_action(menu.get("action"))
resultat["kind"] = model_action
if model_action != "ir.actions.act_window" or not ident:
# Une action client n'a pas d'arch à rendre, une action serveur
# ÉCRIT. Ni l'une ni l'autre ne se teste en ouvrant une page.
return resultat
fields = ["res_model", "view_mode", "domain", "context", "limit"]
lst_action, error = session.call_kw(
"ir.actions.act_window", "read", [[ident], fields]
)
if error:
resultat["error"] = error
resultat["stage"] = "action"
return resultat
if not lst_action:
return resultat
action = lst_action[0]
model = action.get("res_model")
resultat["model"] = model
if not model:
return resultat
domain, brut_domaine = literal(action.get("domain"), [])
context, brut_contexte = literal(action.get("context"), {})
if not isinstance(domain, list):
domain, brut_domaine = [], True
if not isinstance(context, dict):
context, brut_contexte = {}, True
resultat["domain_ignored"] = brut_domaine or brut_contexte
views, error = session.views_of(
model, view_pairs(action.get("view_mode")), context
)
if error:
resultat["error"] = error
resultat["stage"] = (
"registry" if model_is_unregistered(error) else "views"
)
return resultat
# Les champs de la page, et ceux que le modèle porte vraiment. L'écart
# entre les deux EST le dégât d'une migration : une vue qui nomme un
# champ disparu. Certaines versions le laissent passer au rendu et
# n'échouent qu'à la lecture — autant le nommer tout de suite.
lst_arch = arch_fields(views)
lst_known, error = session.known_fields(model, context)
if error:
resultat["error"] = error
resultat["stage"] = "fields"
return resultat
resultat["unknown_fields"] = [
name for name in lst_arch if name not in lst_known
]
lst_read = [name for name in lst_arch if name in lst_known]
resultat["fields_read"] = len(lst_read)
_rows, error = session.first_page(
model,
domain,
context,
min(limit, action.get("limit") or limit),
lst_field=lst_read or ["display_name"],
)
if error:
resultat["error"] = error
resultat["stage"] = "records"
return resultat
def crawl(session, limit=DEFAULT_RECORD_LIMIT, every_menu=False):
"""Parcourir les applications. Rend (entrées visitées, échecs)."""
lst_menu, error = menu_rows(session)
if error:
raise RuntimeError(
f"{t('Could not read the menus')} : {error['name']}"
f" {error['message']}"[:300]
)
if every_menu:
par_id = {menu["id"]: menu for menu in lst_menu}
lst_entry = [
(_root_of(menu, par_id), menu) for menu in actionable(lst_menu)
]
else:
lst_entry = [
(app, first) for app, first in apps(lst_menu) if first is not None
]
lst_result = [
check_entry(session, app, menu, limit=limit) for app, menu in lst_entry
]
return lst_result, [item for item in lst_result if item["error"]]
def _root_of(menu, par_id):
"""Remonter jusqu'à l'application, pour dire d'où vient le menu."""
vus = set()
courant = menu
while True:
parent = courant.get("parent_id")
cle = parent[0] if isinstance(parent, (list, tuple)) else None
if cle is None or cle in vus or cle not in par_id:
return courant
vus.add(cle)
courant = par_id[cle]
def render(lst_result, lst_failure):
"""Le rapport. Ce qui a ouvert, et ce qui a refusé de s'ouvrir."""
lignes = []
lignes.append(
f"\n✨ {t('Apps opened as the test user')} :"
f" {len(lst_result) - len(lst_failure)}/{len(lst_result)}"
)
saute = [
item
for item in lst_result
if not item["error"] and item["kind"] != "ir.actions.act_window"
]
if saute:
lignes.append(
f" {len(saute)} {t('not openable this way (client or server')}"
f" {t('action): nothing to render.')}"
)
approx = [item for item in lst_result if item["domain_ignored"]]
if approx:
lignes.append(
f" {len(approx)} {t('opened with an empty domain: theirs')}"
f" {t('needs the browser to evaluate it.')}"
)
fantomes = [item for item in lst_result if item.get("unknown_fields")]
if fantomes:
lignes.append(
f"\n⚠️ {t('Views naming a field the model no longer has')} :"
)
for item in fantomes:
lignes.append(
f" · {item['app']} [{item['model']}] :"
f" {', '.join(item['unknown_fields'][:8])}"
)
if not lst_failure:
lignes.append(f"✅ {t('Every app opened its first page.')}")
return "\n".join(lignes)
lignes.append(f"\n❌ {t('Apps that failed to open')} :")
ETAPE = {
"action": t("reading the action"),
"views": t("rendering the views"),
"fields": t("listing the model fields"),
"records": t("loading the first records"),
"registry": t("model absent from the running registry"),
}
for item in lst_failure:
error = item["error"]
lignes.append(
f" · {item['app']} → {item['menu']}"
f" [{item['model'] or '-'}]"
)
lignes.append(
f" {ETAPE.get(item['stage'], item['stage'])} :"
f" {error['name']}"
)
if item["stage"] == "registry":
# Le 404 nu d'Odoo ne dit rien d'utile ; ce qu'il faut savoir,
# c'est que le code du module manque à CETTE version.
lignes.append(
f" {t('installed in the database, but its module code')}"
f" {t('is not in this addons path.')}"
)
else:
message = (error.get("message") or "").strip().splitlines()
if message:
lignes.append(f" {message[0][:160]}")
lignes.append(
f"\nℹ {t('These are back-office failures: the public smoke test')}"
f" {t('cannot see them.')}"
)
return "\n".join(lignes)
def run(base_url, database, login, password, limit, every_menu=False):
"""Se connecter puis parcourir. Lève RuntimeError si l'on ne peut pas."""
session = Session(base_url)
ok, raison = session.log_in(database, login, password)
if not ok:
raise RuntimeError(f"{t('Could not log in as')} '{login}' : {raison}")
return crawl(session, limit=limit, every_menu=every_menu)
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Log in as the neutralization test user and open the first page"
" of every Odoo app, reporting what fails."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="./config.conf")
parser.add_argument("-p", "--port", type=int, default=None)
parser.add_argument("--login", default=DEFAULT_LOGIN)
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_RECORD_LIMIT,
help="how many records to load per app",
)
parser.add_argument(
"--all-menus",
action="store_true",
help="open every menu with an action, not just each app's first page",
)
parser.add_argument(
"--boot-timeout",
type=int,
default=180,
help="how long to wait for the server to answer",
)
config = parser.parse_args(argv)
from smoke_public_url import (
DEFAULT_PORT,
port_is_taken,
start_server,
stop_server,
wait_ready,
)
port = config.port or DEFAULT_PORT
etat = user_state(config.database, config.login)
if etat == "unknown":
# Ne PAS dire « pas neutralisée » : on ne le sait pas. Le dire
# ferait croire que le back-office va bien alors qu'on n'a rien vu.
print(
f"❌ {t('Could not tell whether the')} '{config.login}'"
f" {t('user exists in')} '{config.database}'."
)
return 2
if etat == "absent":
print(
f"ℹ️ {t('No')} '{config.login}' {t('user in')}"
f" '{config.database}' :"
f" {t('the database was not neutralized, nothing to browse.')}"
)
return 0
if port_is_taken(port):
print(
f"❌ {t('Something already listens on port')} {port} :"
f" {t('it would be tested instead of this database.')}"
)
return 2
base_url = f"http://127.0.0.1:{port}"
print(
f"⧖ {t('Starting Odoo on')} '{config.database}'"
f" ({t('port')} {port})…"
)
server = start_server(config.database, port, config.config)
try:
if not wait_ready(base_url, timeout=config.boot_timeout):
print(f"❌ {t('The server never answered on')} {base_url}")
return 2
lst_result, lst_failure = run(
base_url,
config.database,
config.login,
config.password,
config.limit,
every_menu=config.all_menus,
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
finally:
stop_server(server)
print(render(lst_result, lst_failure))
return 1 if lst_failure else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -430,6 +430,65 @@ def prompt(database, lst_failure, lst_key, ask=None):
return lst_chosen
def render_internal(internal):
"""Afficher le rapport du back-office. Rend True s'il a échoué.
Ne rien afficher quand la base n'a pas été neutralisée serait laisser
croire que le back-office a été testé et qu'il va bien.
"""
if internal is None:
return False
if "skipped" in internal:
print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}")
return False
import smoke_internal_ui
print(smoke_internal_ui.render(internal["results"], internal["failures"]))
return bool(internal["failures"])
def internal_phase(
base_url,
database,
enabled=True,
login="test",
password="test",
limit=20,
every_menu=False,
):
"""Le back-office, si la base a été neutralisée. Rend None sinon.
La condition n'est pas un drapeau mais l'utilisateur lui-même : la
neutralisation pose un compte `test` avec les groupes du
superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot
de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le
fichier de progression dirait quand même « fait ».
Un échec ICI ne doit pas emporter le test public : il est mesuré, il
est rapporté, mais le rapport des URL publiques a sa propre valeur.
"""
if not enabled:
return None
try:
import smoke_internal_ui
except ImportError:
return None
etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
if etat == "absent":
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")}
try:
lst_result, lst_failure = smoke_internal_ui.run(
base_url, database, login, password, limit, every_menu=every_menu
)
except RuntimeError as exc:
return {"skipped": str(exc)}
return {"results": lst_result, "failures": lst_failure}
def run(
database,
port,
@ -440,6 +499,11 @@ def run(
interactive=False,
auto_apply=False,
ask=input,
internal=True,
internal_login="test",
internal_password="test",
internal_limit=20,
every_menu=False,
):
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
@ -472,6 +536,18 @@ def run(
if limit:
lst_url = lst_url[:limit]
lst_failure = check_urls(lst_url, timeout=timeout)
# ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce
# qui coûte des minutes, pas les requêtes. Un deuxième outil avec
# son propre serveur doublerait l'attente pour rien.
internal_report = internal_phase(
base_url,
database,
enabled=internal,
login=internal_login,
password=internal_password,
limit=internal_limit,
every_menu=every_menu,
)
finally:
stop_server(server)
@ -486,7 +562,7 @@ def run(
if key not in lst_key:
lst_key.append(key)
if not lst_failure or not (interactive or auto_apply):
return lst_url, lst_failure, lst_key, None
return lst_url, lst_failure, lst_key, None, internal_report
print(render(lst_url, lst_failure, lst_key))
if auto_apply:
@ -499,7 +575,7 @@ def run(
else:
lst_done = prompt(database, lst_failure, lst_key, ask=ask)
if not lst_done:
return lst_url, lst_failure, lst_key, None
return lst_url, lst_failure, lst_key, None, internal_report
server = start_server(database, port, config_path, log_path=log_path)
try:
@ -512,7 +588,7 @@ def run(
)
finally:
stop_server(server)
return lst_url, lst_failure, lst_key, lst_again
return lst_url, lst_failure, lst_key, lst_again, internal_report
def stop_server(server):
@ -582,6 +658,24 @@ def main(argv=None):
default=180,
help="how long to wait for the server to answer",
)
parser.add_argument(
"--no-internal",
action="store_true",
help="skip the back-office pass done as the neutralization test user",
)
parser.add_argument("--login", default="test")
parser.add_argument("--password", default="test")
parser.add_argument(
"--record-limit",
type=int,
default=20,
help="how many records each app loads on its first page",
)
parser.add_argument(
"--all-menus",
action="store_true",
help="open every menu with an action, not just each app's first page",
)
config = parser.parse_args(argv)
print(
@ -590,7 +684,7 @@ def main(argv=None):
)
interactive = not config.report_only and can_ask()
try:
lst_url, lst_failure, lst_key, lst_again = run(
lst_url, lst_failure, lst_key, lst_again, internal = run(
config.database,
config.port,
config.config,
@ -599,20 +693,26 @@ def main(argv=None):
boot=config.boot_timeout,
interactive=interactive,
auto_apply=config.apply,
internal=not config.no_internal,
internal_login=config.login,
internal_password=config.password,
internal_limit=config.record_limit,
every_menu=config.all_menus,
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
internal_failed = render_internal(internal)
if lst_again is None:
print(render(lst_url, lst_failure, lst_key))
return 1 if lst_failure else 0
return 1 if (lst_failure or internal_failed) else 0
# Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE.
print(
f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
f" {t('failing URL(s) after the reset')} :"
)
print(render([url for url, _s, _p in lst_failure], lst_again, None))
return 1 if lst_again else 0
return 1 if (lst_again or internal_failed) else 0
if __name__ == "__main__":

View file

@ -5286,6 +5286,130 @@ TRANSLATIONS = {
"fr": "garder",
"en": "keep",
},
"The server did not serve the login page.": {
"fr": "Le serveur n'a pas servi la page de connexion.",
"en": "The server did not serve the login page.",
},
"Wrong login or password for user": {
"fr": "Identifiant ou mot de passe refusé pour l'utilisateur",
"en": "Wrong login or password for user",
},
"Could not read the menus": {
"fr": "Impossible de lire les menus",
"en": "Could not read the menus",
},
"Apps opened as the test user": {
"fr": "Applications ouvertes avec l'utilisateur test",
"en": "Apps opened as the test user",
},
"not openable this way (client or server": {
"fr": "non ouvrables ainsi (action client ou",
"en": "not openable this way (client or server",
},
"action): nothing to render.": {
"fr": "serveur) : rien à rendre.",
"en": "action): nothing to render.",
},
"opened with an empty domain: theirs": {
"fr": "ouvertes avec un domaine vide : le leur",
"en": "opened with an empty domain: theirs",
},
"needs the browser to evaluate it.": {
"fr": "demande le navigateur pour être évalué.",
"en": "needs the browser to evaluate it.",
},
"Every app opened its first page.": {
"fr": "Chaque application a ouvert sa première page.",
"en": "Every app opened its first page.",
},
"Apps that failed to open": {
"fr": "Applications qui n'ont pas pu s'ouvrir",
"en": "Apps that failed to open",
},
"reading the action": {
"fr": "lecture de l'action",
"en": "reading the action",
},
"rendering the views": {
"fr": "rendu des vues",
"en": "rendering the views",
},
"loading the first records": {
"fr": "chargement des premiers enregistrements",
"en": "loading the first records",
},
"These are back-office failures: the public smoke test": {
"fr": "Ce sont des échecs du back-office : le test de fumée public",
"en": "These are back-office failures: the public smoke test",
},
"cannot see them.": {
"fr": "ne peut pas les voir.",
"en": "cannot see them.",
},
"Could not log in as": {
"fr": "Connexion impossible en tant que",
"en": "Could not log in as",
},
"No": {
"fr": "Aucun",
"en": "No",
},
"user in": {
"fr": "utilisateur dans",
"en": "user in",
},
"the database was not neutralized, nothing to browse.": {
"fr": "la base n'a pas été neutralisée, rien à parcourir.",
"en": "the database was not neutralized, nothing to browse.",
},
"Back office not browsed": {
"fr": "Back-office non parcouru",
"en": "Back office not browsed",
},
"no test user: the database was not neutralized": {
"fr": "pas d'utilisateur test : la base n'a pas été neutralisée",
"en": "no test user: the database was not neutralized",
},
"Could not tell whether the": {
"fr": "Impossible de savoir si l'utilisateur",
"en": "Could not tell whether the",
},
"user exists in": {
"fr": "existe dans",
"en": "user exists in",
},
"could not tell whether the test user exists": {
"fr": "impossible de savoir si l'utilisateur test existe",
"en": "could not tell whether the test user exists",
},
"listing the model fields": {
"fr": "inventaire des champs du modèle",
"en": "listing the model fields",
},
"model absent from the running registry": {
"fr": "modèle absent du registre en cours",
"en": "model absent from the running registry",
},
"installed in the database, but its module code": {
"fr": "installé dans la base, mais le code de son module",
"en": "installed in the database, but its module code",
},
"is not in this addons path.": {
"fr": "n'est pas dans cet addons path.",
"en": "is not in this addons path.",
},
"Views naming a field the model no longer has": {
"fr": "Vues nommant un champ que le modèle n'a plus",
"en": "Views naming a field the model no longer has",
},
"Public pages, then the back office as the test user": {
"fr": "Les pages publiques, puis le back-office avec l'utilisateur test",
"en": "Public pages, then the back office as the test user",
},
"if the database was neutralized.": {
"fr": "si la base a été neutralisée.",
"en": "if the database was neutralized.",
},
"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

@ -3017,8 +3017,11 @@ class TodoUpgrade:
de départ, les mêmes 500 après le palier passent pour un dégât de la
migration, et l'on cherche du mauvais côté.
« non » par défaut : cela démarre un serveur et peut prendre quelques
minutes sur un gros site.
Le serveur ainsi démarré sert AUSSI au back-office : si la base a
été neutralisée, l'outil se connecte avec l'utilisateur `test` que
la neutralisation y a posé et ouvre la première page de chaque
application. Un deuxième outil avec son propre serveur doublerait
l'attente — le démarrage d'Odoo est ce qui coûte, pas les requêtes.
"""
if baseline:
print(
@ -3037,6 +3040,10 @@ 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.')}"
)
self.run_on_terminal(
f"{PYTHON_BIN}"
" ./script/odoo/migration/smoke_public_url.py"

View file

@ -0,0 +1,498 @@
#!/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"])
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()