diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py new file mode 100755 index 0000000..4d659ca --- /dev/null +++ b/script/odoo/migration/smoke_internal_ui.py @@ -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 + `` 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 ``. + + 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()) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index dd6023e..f0806c5 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -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__": diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ead6173..623e8da 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index d4e693a..d475978 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -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" diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py new file mode 100644 index 0000000..3f4c187 --- /dev/null +++ b/test/test_smoke_internal_ui.py @@ -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": ( + "
" + '' + '' + '' + '' + "" + '' + "" + ) + } + } + } + + 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": ''}} + self.assertEqual(ui.arch_fields(views), []) + + def test_an_unreadable_arch_yields_nothing_rather_than_nonsense(self): + views = {"a": {"arch": "
'} + } + } + 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()