erplibre/test/test_smoke_final_url.py

302 lines
9.8 KiB
Python
Raw Normal View History

[FIX] smoke: name the URL that actually failed, keep the previous log The report named the sitemap URL, never the one the redirect chain ended on. On this site every page goes through two or three hops -- measured, 146 for 55 pages, between the language prefix and the canonical slug -- so a 500 at the end was reported against a page that answers perfectly well. One goes and checks it, finds it healthy, and concludes the tool is wrong. urllib carries the answer: HTTPError.url is the URL that produced the error, not the one requested. fetch now returns it and the report shows it when it differs. A timeout was never reported as 500 -- fetch returns 0 and the report writes "no answer" -- but the evidence for a real 500 did evaporate: the Odoo log was opened with "w", so replaying the test erased the trace of the failure one had just seen. One generation is kept now. --- FR --- Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de redirections aboutit. Sur ce site chaque page en traverse deux ou trois — mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une page qui répond très bien. On va la vérifier, on la trouve saine, et l'on conclut que l'outil se trompe. urllib porte la réponse : HTTPError.url est l'URL qui a produit l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère. Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch` rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500 s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le test effaçait la trace qu'on venait de voir. Une génération est gardée. Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'URL qui a échoué n'est pas toujours celle qu'on a demandée.
Sur un site Odoo, chaque page traverse deux ou trois redirections —
mesuré, 146 pour 55 pages, entre la langue et le slug canonique. Quand
la DERNIÈRE rend 500, l'outil nommait la première : on allait vérifier
une page parfaitement saine et l'on concluait que le test se trompait.
Et un dépassement de délai ne rend PAS 500 : `fetch` rend 0, que le
rapport écrit « aucune réponse ». Confondre les deux enverrait chercher
une lenteur là où le serveur a répondu par une erreur.
"""
import http.server
import io
import os
import socketserver
import sys
import threading
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import smoke_public_url as smoke # noqa: E402
from script.todo import todo_i18n # noqa: E402
class Chaine(http.server.BaseHTTPRequestHandler):
"""/depart → 303 → /milieu → 303 → /fin, qui décide."""
fin_status = 500
def do_GET(self):
if self.path == "/depart":
self.send_response(303)
self.send_header("Location", "/milieu")
self.end_headers()
elif self.path == "/milieu":
self.send_response(303)
self.send_header("Location", "/fin")
self.end_headers()
elif self.path == "/direct":
self.send_response(500)
self.end_headers()
self.wfile.write(b"boum")
else:
self.send_response(self.fin_status)
self.end_headers()
self.wfile.write(b"boum")
def log_message(self, *args):
pass
class TestFetchFollowsTheChain(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.srv = socketserver.TCPServer(("127.0.0.1", 0), Chaine)
cls.port = cls.srv.server_address[1]
cls.fil = threading.Thread(target=cls.srv.serve_forever, daemon=True)
cls.fil.start()
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
def url(self, chemin):
return f"http://127.0.0.1:{self.port}{chemin}"
def test_it_reports_the_url_that_actually_failed(self):
# LE point : la 500 est au bout de la chaîne, pas au départ.
statut, _corps, finale = smoke.fetch(self.url("/depart"), timeout=5)
self.assertEqual(statut, 500)
self.assertTrue(finale.endswith("/fin"), finale)
def test_without_a_redirect_both_are_the_same(self):
statut, _corps, finale = smoke.fetch(self.url("/direct"), timeout=5)
self.assertEqual(statut, 500)
self.assertEqual(finale, self.url("/direct"))
def test_a_success_also_carries_its_final_url(self):
Chaine.fin_status = 200
try:
statut, _corps, finale = smoke.fetch(
self.url("/depart"), timeout=5
)
self.assertEqual(statut, 200)
self.assertTrue(finale.endswith("/fin"), finale)
finally:
Chaine.fin_status = 500
def test_a_dead_host_is_zero_NOT_five_hundred(self):
# C'est ce qui distingue « le serveur a répondu par une erreur »
# de « il n'a pas répondu ». Les confondre envoie chercher une
# lenteur là où il y a une exception.
statut, corps, finale = smoke.fetch(
"http://127.0.0.1:1/jamais", timeout=1
)
self.assertEqual(statut, 0)
self.assertEqual(corps, "")
self.assertEqual(finale, "http://127.0.0.1:1/jamais")
def test_check_urls_keeps_the_final_url(self):
echecs = smoke.check_urls([self.url("/depart")], timeout=5)
self.assertEqual(len(echecs), 1)
url, statut, parents, finale = echecs[0]
self.assertEqual(url, self.url("/depart"))
self.assertEqual(statut, 500)
self.assertEqual(parents, [])
self.assertTrue(finale.endswith("/fin"))
def test_a_page_that_answers_is_not_a_failure(self):
Chaine.fin_status = 200
try:
self.assertEqual(
smoke.check_urls([self.url("/depart")], timeout=5), []
)
finally:
Chaine.fin_status = 500
class TestTheReport(unittest.TestCase):
def test_it_names_the_final_url_when_it_differs(self):
texte = smoke.render(
["a", "b"], [("http://x/depart", 500, [], "http://x/fin")]
)
self.assertIn("http://x/depart", texte)
self.assertIn(todo_i18n.t("failed at"), texte)
self.assertIn("http://x/fin", texte)
def test_it_stays_quiet_when_they_are_the_same(self):
# Répéter la même URL sur deux lignes n'apprend rien et allonge
# un rapport qui peut compter trente-quatre entrées.
texte = smoke.render(
["a"], [("http://x/page", 500, [], "http://x/page")]
)
self.assertNotIn(todo_i18n.t("failed at"), texte)
def test_no_answer_is_worded_apart_from_a_status(self):
texte = smoke.render(
["a"], [("http://x/page", 0, [], "http://x/page")]
)
self.assertIn(todo_i18n.t("no answer"), texte)
self.assertNotIn("[500]", texte)
class TestNothingUnpacksTheFailureTupleBlindly(unittest.TestCase):
"""Ajouter un champ au tuple d'échec a cassé une migration en cours.
Le tuple est passé de trois à quatre éléments et deux sites
dépaquetaient encore trois — `too many values to unpack`, en plein
milieu, APRÈS la réinitialisation d'une copie COW. Le commentaire
« TOUJOURS quatre éléments » ne protège de rien : il faut ne pas
dépaqueter quand on ne veut qu'un champ.
"""
CHEMIN = os.path.join(
os.path.dirname(__file__),
"..",
"script",
"odoo",
"migration",
"smoke_public_url.py",
)
def source(self):
with io.open(self.CHEMIN, encoding="utf-8") as handle:
return handle.read()
def test_no_three_element_unpack_survives(self):
import re
motif = re.compile(
r"for\s+[a-z_]+,\s*[a-z_]+,\s*[a-z_]+\s+in\s+lst_failure"
)
trouves = motif.findall(self.source())
self.assertEqual(trouves, [], f"dépaquetage à trois : {trouves}")
def test_taking_only_the_url_uses_an_index(self):
# Indexer survit au prochain champ ajouté ; dépaqueter non.
self.assertIn("[echec[0] for echec in lst_failure]", self.source())
class TestRecheckingAfterAReset(unittest.TestCase):
"""La passe qui a cassé, exercée pour de vrai.
Elle ne tournait sous aucun test : c'est pourquoi le dépaquetage à
trois y a survécu à la suite complète, aux mutations, et n'est
tombé qu'en production.
"""
def setUp(self):
self.vrais = {
nom: getattr(smoke, nom)
for nom in (
"start_server",
"wait_ready",
"check_urls",
"internal_needs_retry",
"stop_server",
)
if hasattr(smoke, nom)
}
self.vus = []
class FauxServeur:
def __init__(self):
self.arrete = False
smoke.start_server = lambda *a, **k: FauxServeur()
smoke.wait_ready = lambda *a, **k: True
smoke.internal_needs_retry = lambda rapport: False
if hasattr(smoke, "stop_server"):
smoke.stop_server = lambda *a, **k: None
def faux_check(lst_url, timeout=30):
self.vus.append(list(lst_url))
return []
smoke.check_urls = faux_check
def tearDown(self):
for nom, valeur in self.vrais.items():
setattr(smoke, nom, valeur)
def test_it_rechecks_exactly_the_urls_that_had_failed(self):
echecs = [
("http://h/contactus", 500, ["2837"], "http://h/en/contactus"),
("http://h/blog", 500, [], "http://h/blog"),
]
smoke.recheck_after_reset(
"db",
8169,
"./config.conf",
"http://h",
None,
echecs,
{"failures": []},
internal=False,
)
self.assertEqual(self.vus, [["http://h/contactus", "http://h/blog"]])
def test_it_rechecks_the_REQUESTED_url_not_the_final_one(self):
# On revérifie ce que le sitemap publie : c'est cette adresse-là
# que les visiteurs demandent.
echecs = [("http://h/a", 500, [], "http://h/z")]
smoke.recheck_after_reset(
"db",
8169,
"./config.conf",
"http://h",
None,
echecs,
{"failures": []},
internal=False,
)
self.assertEqual(self.vus, [["http://h/a"]])
def test_an_empty_failure_list_rechecks_nothing(self):
smoke.recheck_after_reset(
"db",
8169,
"./config.conf",
"http://h",
None,
[],
{"failures": []},
internal=False,
)
self.assertEqual(self.vus, [[]])
[FIX] smoke: name the URL that actually failed, keep the previous log The report named the sitemap URL, never the one the redirect chain ended on. On this site every page goes through two or three hops -- measured, 146 for 55 pages, between the language prefix and the canonical slug -- so a 500 at the end was reported against a page that answers perfectly well. One goes and checks it, finds it healthy, and concludes the tool is wrong. urllib carries the answer: HTTPError.url is the URL that produced the error, not the one requested. fetch now returns it and the report shows it when it differs. A timeout was never reported as 500 -- fetch returns 0 and the report writes "no answer" -- but the evidence for a real 500 did evaporate: the Odoo log was opened with "w", so replaying the test erased the trace of the failure one had just seen. One generation is kept now. --- FR --- Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de redirections aboutit. Sur ce site chaque page en traverse deux ou trois — mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une page qui répond très bien. On va la vérifier, on la trouve saine, et l'on conclut que l'outil se trompe. urllib porte la réponse : HTTPError.url est l'URL qui a produit l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère. Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch` rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500 s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le test effaçait la trace qu'on venait de voir. Une génération est gardée. Assisted-by: Claude Opus 5
2026-08-22 03:19:45 -04:00
class TestTheLogSurvives(unittest.TestCase):
def test_the_previous_run_is_kept(self):
# Le journal était ouvert en « w » : relancer le test effaçait la
# trace de l'échec qu'on venait de voir.
with io.open(
os.path.join(
os.path.dirname(__file__),
"..",
"script",
"odoo",
"migration",
"smoke_public_url.py",
),
encoding="utf-8",
) as handle:
src = handle.read()
debut = src.index("def start_server")
fin = src.index("subprocess.Popen", debut)
self.assertIn("os.replace(log_path, log_path", src[debut:fin])
if __name__ == "__main__":
unittest.main()