339 lines
13 KiB
Python
339 lines
13 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
"""Ce qu'une montée de version ajoute au référentiel comptable.
|
||
|
|
|
||
|
|
Mesuré sur une migration 12 → 18 réelle, entre les deux derniers paliers :
|
||
|
|
|
||
|
|
comptes 96 → 427 taxes 32 → 64
|
||
|
|
groupes 0 → 168 positions 14 → 28
|
||
|
|
|
||
|
|
Le script de migration de `l10n_ca` recharge le plan `ca_2023` sans
|
||
|
|
`force_create=False` ; les codes du client ne recouvrent le gabarit que
|
||
|
|
sur 15 des 341, et les 326 autres sont créés. Rien n'est détruit — mais
|
||
|
|
les 168 groupes reclassent les comptes du client par PRÉFIXE de code, et
|
||
|
|
un compte fournisseurs de 1067 écritures se retrouve sous « Residential
|
||
|
|
Mortgage Loans ».
|
||
|
|
|
||
|
|
Ce qui se teste ici n'est pas le comptage — psql le fait — mais le
|
||
|
|
JUGEMENT : un compte absolu ne dit rien (un plan peut légitimement porter
|
||
|
|
mille comptes), seul l'écart entre deux paliers de la même migration se
|
||
|
|
juge. Et la comparaison doit rester muette quand rien n'a bougé, sinon
|
||
|
|
personne ne la lira deux fois.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import io
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from contextlib import redirect_stdout
|
||
|
|
|
||
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
|
sys.path.insert(0, REPO)
|
||
|
|
|
||
|
|
from script.analyse import check_chart_drift as drift # noqa: E402
|
||
|
|
from script.analyse import lib_analyse # noqa: E402
|
||
|
|
|
||
|
|
|
||
|
|
class Base(unittest.TestCase):
|
||
|
|
"""psql bouchonné : chaque base rend les comptes qu'on lui prête."""
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
self.vues = []
|
||
|
|
self.ancien = lib_analyse.run_psql
|
||
|
|
self.addCleanup(setattr, lib_analyse, "run_psql", self.ancien)
|
||
|
|
self.tables = {}
|
||
|
|
self.sans_ecriture = (0, 0)
|
||
|
|
self.homonymes = 0
|
||
|
|
self.reclasses = 0
|
||
|
|
lib_analyse.run_psql = self.repondre
|
||
|
|
|
||
|
|
def poser(self, base, **compteurs):
|
||
|
|
self.tables[base] = compteurs
|
||
|
|
|
||
|
|
def repondre(self, database, sql, **kwargs):
|
||
|
|
self.vues.append((database, sql))
|
||
|
|
compteurs = self.tables.get(database, {})
|
||
|
|
if "to_regclass" in sql and "count(*)" not in sql:
|
||
|
|
table = sql.split("public.")[1].split("'")[0]
|
||
|
|
return "true" if table in compteurs else "false"
|
||
|
|
if "account_move_line l WHERE l.account_id" in sql:
|
||
|
|
total, vides = self.sans_ecriture
|
||
|
|
return '[{"total": %d, "sans_ecriture": %d}]' % (total, vides)
|
||
|
|
if "HAVING count(*) > 1" in sql:
|
||
|
|
return str(self.homonymes)
|
||
|
|
if "code_prefix_start" in sql:
|
||
|
|
return str(self.reclasses)
|
||
|
|
for table, valeur in compteurs.items():
|
||
|
|
if f"FROM {table};" in sql:
|
||
|
|
return str(valeur)
|
||
|
|
return ""
|
||
|
|
|
||
|
|
|
||
|
|
class TestWhatItCompares(Base):
|
||
|
|
def test_it_reads_both_steps(self):
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
drift.inspect("apres", "avant")
|
||
|
|
bases = {base for base, _sql in self.vues}
|
||
|
|
self.assertEqual({"avant", "apres"}, bases)
|
||
|
|
|
||
|
|
def test_a_table_that_does_not_exist_is_not_a_zero(self):
|
||
|
|
# Sans le module account, la table manque : dire « 0 → 0 » ferait
|
||
|
|
# croire à une mesure, et « 0 → 400 » à une explosion.
|
||
|
|
self.poser("avant")
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
rapport = drift.inspect("apres", "avant")
|
||
|
|
ligne = [x for x in rapport["tables"] if x["key"] == "account"][0]
|
||
|
|
self.assertIsNone(ligne["before"])
|
||
|
|
self.assertIsNone(ligne["delta"])
|
||
|
|
|
||
|
|
def test_every_declared_table_is_measured(self):
|
||
|
|
self.poser("avant", account_account=1)
|
||
|
|
self.poser("apres", account_account=1)
|
||
|
|
rapport = drift.inspect("apres", "avant")
|
||
|
|
self.assertEqual(
|
||
|
|
{e["key"] for e in drift.TABLES},
|
||
|
|
{x["key"] for x in rapport["tables"]},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestWhatItJudges(Base):
|
||
|
|
def rapport(self, avant, apres):
|
||
|
|
self.poser("avant", **avant)
|
||
|
|
self.poser("apres", **apres)
|
||
|
|
return drift.inspect("apres", "avant")
|
||
|
|
|
||
|
|
def test_nothing_added_is_nothing_to_report(self):
|
||
|
|
rapport = self.rapport(
|
||
|
|
{"account_account": 96}, {"account_account": 96}
|
||
|
|
)
|
||
|
|
self.assertEqual([], drift.judge(rapport))
|
||
|
|
|
||
|
|
def test_a_chart_that_quadruples_is_a_finding(self):
|
||
|
|
rapport = self.rapport(
|
||
|
|
{"account_account": 96}, {"account_account": 427}
|
||
|
|
)
|
||
|
|
trouve = drift.judge(rapport)
|
||
|
|
self.assertEqual(1, len(trouve))
|
||
|
|
self.assertEqual(331, trouve[0][1]["delta"])
|
||
|
|
|
||
|
|
def test_a_chart_that_SHRINKS_is_not_reported_here(self):
|
||
|
|
# Ce n'est pas le sujet de cet outil, et le dire ferait deux
|
||
|
|
# constats pour deux causes différentes sous un seul nom.
|
||
|
|
rapport = self.rapport(
|
||
|
|
{"account_account": 427}, {"account_account": 96}
|
||
|
|
)
|
||
|
|
self.assertEqual([], drift.judge(rapport))
|
||
|
|
|
||
|
|
def test_groups_appearing_from_nothing_are_judged(self):
|
||
|
|
# Zéro groupe puis cent soixante-huit : c'est la grille du gabarit,
|
||
|
|
# et elle reclasse les comptes du client.
|
||
|
|
rapport = self.rapport({"account_group": 0}, {"account_group": 168})
|
||
|
|
self.assertEqual("group", drift.judge(rapport)[0][0]["key"])
|
||
|
|
|
||
|
|
def test_gravity_wins_over_size(self):
|
||
|
|
rapport = self.rapport(
|
||
|
|
{"account_journal": 8, "account_account": 96},
|
||
|
|
{"account_journal": 28, "account_account": 99},
|
||
|
|
)
|
||
|
|
trouve = drift.judge(rapport)
|
||
|
|
cles = [entree["key"] for entree, _ in trouve]
|
||
|
|
self.assertEqual("account", cles[0], "la gravite doit primer")
|
||
|
|
self.assertEqual("journal", cles[-1])
|
||
|
|
# Et l'écart le plus GROS est bien celui qu'on relègue.
|
||
|
|
deltas = dict(zip(cles, [ligne["delta"] for _e, ligne in trouve]))
|
||
|
|
self.assertGreater(deltas["journal"], deltas["account"])
|
||
|
|
|
||
|
|
def test_at_equal_gravity_the_bigger_gap_comes_first(self):
|
||
|
|
rapport = self.rapport(
|
||
|
|
{"account_account": 96, "account_tax": 32},
|
||
|
|
{"account_account": 427, "account_tax": 64},
|
||
|
|
)
|
||
|
|
cles = [entree["key"] for entree, _ in drift.judge(rapport)]
|
||
|
|
self.assertEqual(["account", "tax"], cles)
|
||
|
|
|
||
|
|
def test_every_entry_says_why_it_matters(self):
|
||
|
|
for entree in drift.TABLES:
|
||
|
|
self.assertTrue(entree["why"].strip(), entree["key"])
|
||
|
|
self.assertIn(entree["gravity"], ("broken", "watch"))
|
||
|
|
|
||
|
|
|
||
|
|
class TestTheReport(Base):
|
||
|
|
def test_it_names_both_steps(self):
|
||
|
|
self.poser("base_upgrade_17", account_account=96)
|
||
|
|
self.poser("base_upgrade_18", account_account=427)
|
||
|
|
rapport = drift.inspect("base_upgrade_18", "base_upgrade_17")
|
||
|
|
texte = drift.render(rapport, colour=False)
|
||
|
|
self.assertIn("base_upgrade_17", texte)
|
||
|
|
self.assertIn("base_upgrade_18", texte)
|
||
|
|
|
||
|
|
def test_it_shows_the_before_and_the_after_not_just_the_delta(self):
|
||
|
|
# « +331 » seul ne dit pas si l'on part de 96 ou de 4000.
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
texte = drift.render(drift.inspect("apres", "avant"), colour=False)
|
||
|
|
self.assertIn("+331", texte)
|
||
|
|
self.assertIn("96", texte)
|
||
|
|
self.assertIn("427", texte)
|
||
|
|
|
||
|
|
def test_a_quiet_migration_says_so_and_stops(self):
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=96)
|
||
|
|
texte = drift.render(drift.inspect("apres", "avant"), colour=False)
|
||
|
|
self.assertIn(
|
||
|
|
drift.t("Nothing was added to the reference data."), texte
|
||
|
|
)
|
||
|
|
self.assertNotIn("❌", texte)
|
||
|
|
|
||
|
|
def test_it_points_at_the_replay_not_at_surgery(self):
|
||
|
|
# La clé naturelle de suppression attrape aussi des comptes du
|
||
|
|
# client, et douze des ajoutés sont accrochés en SET NULL.
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
texte = drift.render(drift.inspect("apres", "avant"), colour=False)
|
||
|
|
self.assertIn(
|
||
|
|
drift.t("Replay the step with the fix; do not operate the"),
|
||
|
|
texte,
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_the_shape_is_shown_when_there_is_something_to_show(self):
|
||
|
|
self.sans_ecriture = (427, 404)
|
||
|
|
self.homonymes = 15
|
||
|
|
self.reclasses = 422
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427, account_group=168)
|
||
|
|
texte = drift.render(drift.inspect("apres", "avant"), colour=False)
|
||
|
|
self.assertIn("404", texte)
|
||
|
|
self.assertIn("422", texte)
|
||
|
|
|
||
|
|
|
||
|
|
class TestFindingThePreviousStep(unittest.TestCase):
|
||
|
|
"""Le palier précédent se LIT dans la chaîne, il ne se devine pas."""
|
||
|
|
|
||
|
|
def chaine(self):
|
||
|
|
return {
|
||
|
|
"config_database_name": "base",
|
||
|
|
"target_odoo_version": "18.0",
|
||
|
|
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
|
||
|
|
}
|
||
|
|
|
||
|
|
def test_it_reads_the_chain_the_migration_wrote(self):
|
||
|
|
self.assertEqual(
|
||
|
|
"base_upgrade_17",
|
||
|
|
drift.previous_database("base_upgrade_18", self.chaine()),
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_the_first_step_has_no_predecessor(self):
|
||
|
|
# La base de DÉPART ne porte pas de suffixe : lui retrancher 1
|
||
|
|
# donnerait un nom qui n'existe pas.
|
||
|
|
self.assertIsNone(drift.previous_database("base", self.chaine()))
|
||
|
|
|
||
|
|
def test_a_database_outside_the_chain_is_not_guessed(self):
|
||
|
|
self.assertIsNone(
|
||
|
|
drift.previous_database("une_autre_base", self.chaine())
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestTheCommand(Base):
|
||
|
|
def lancer(self, argv):
|
||
|
|
tampon = io.StringIO()
|
||
|
|
with redirect_stdout(tampon):
|
||
|
|
code = drift.main(argv)
|
||
|
|
return code, tampon.getvalue()
|
||
|
|
|
||
|
|
def setUp(self):
|
||
|
|
super().setUp()
|
||
|
|
self.ancien_require = lib_analyse.require_odoo_database
|
||
|
|
self.addCleanup(
|
||
|
|
setattr, lib_analyse, "require_odoo_database", self.ancien_require
|
||
|
|
)
|
||
|
|
lib_analyse.require_odoo_database = lambda *a, **k: None
|
||
|
|
|
||
|
|
def test_without_a_previous_step_it_is_a_tool_failure(self):
|
||
|
|
# Sortir 0 laisserait croire que la comparaison a eu lieu.
|
||
|
|
ancien = drift.previous_database
|
||
|
|
drift.previous_database = lambda *a, **k: None
|
||
|
|
self.addCleanup(setattr, drift, "previous_database", ancien)
|
||
|
|
code, _ = self.lancer(["-d", "base_upgrade_18"])
|
||
|
|
self.assertEqual(2, code)
|
||
|
|
|
||
|
|
def test_no_drift_exits_zero(self):
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=96)
|
||
|
|
code, _ = self.lancer(
|
||
|
|
["-d", "apres", "--before", "avant", "--no-color"]
|
||
|
|
)
|
||
|
|
self.assertEqual(0, code)
|
||
|
|
|
||
|
|
def test_drift_exits_one(self):
|
||
|
|
# 1 = des trouvailles, la convention des outils du dépôt.
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
code, _ = self.lancer(
|
||
|
|
["-d", "apres", "--before", "avant", "--no-color"]
|
||
|
|
)
|
||
|
|
self.assertEqual(1, code)
|
||
|
|
|
||
|
|
def test_the_json_carries_both_counts(self):
|
||
|
|
import json
|
||
|
|
|
||
|
|
self.poser("avant", account_account=96)
|
||
|
|
self.poser("apres", account_account=427)
|
||
|
|
_code, texte = self.lancer(
|
||
|
|
["-d", "apres", "--before", "avant", "--json"]
|
||
|
|
)
|
||
|
|
dct = json.loads(texte)
|
||
|
|
ligne = [x for x in dct["tables"] if x["key"] == "account"][0]
|
||
|
|
self.assertEqual(96, ligne["before"])
|
||
|
|
self.assertEqual(427, ligne["after"])
|
||
|
|
self.assertEqual(331, ligne["delta"])
|
||
|
|
|
||
|
|
|
||
|
|
class TestItNeverWrites(unittest.TestCase):
|
||
|
|
"""On compare deux bases de palier : l'une d'elles est parfois la
|
||
|
|
seule copie d'un état."""
|
||
|
|
|
||
|
|
def test_no_statement_of_the_tool_can_write(self):
|
||
|
|
"""Sur le CODE, jamais sur la prose.
|
||
|
|
|
||
|
|
Les docstrings de l'outil expliquent justement pourquoi il ne
|
||
|
|
faut pas opérer la base — « accrochés en ON DELETE SET NULL » —
|
||
|
|
et une garde qui lit le texte échoue sur le texte qui l'explique.
|
||
|
|
"""
|
||
|
|
import ast
|
||
|
|
import inspect
|
||
|
|
|
||
|
|
arbre = ast.parse(inspect.getsource(drift))
|
||
|
|
for noeud in ast.walk(arbre):
|
||
|
|
if isinstance(
|
||
|
|
noeud, (ast.Module, ast.FunctionDef, ast.ClassDef)
|
||
|
|
) and ast.get_docstring(noeud):
|
||
|
|
noeud.body = noeud.body[1:]
|
||
|
|
code = ast.unparse(arbre).upper()
|
||
|
|
for interdit in ("UPDATE ", "DELETE ", "INSERT ", "DROP ", "ALTER "):
|
||
|
|
self.assertNotIn(interdit, code, interdit)
|
||
|
|
|
||
|
|
def test_it_only_ever_selects(self):
|
||
|
|
import ast
|
||
|
|
import inspect
|
||
|
|
|
||
|
|
arbre = ast.parse(inspect.getsource(drift))
|
||
|
|
requetes = [
|
||
|
|
n.value
|
||
|
|
for n in ast.walk(arbre)
|
||
|
|
if isinstance(n, ast.Constant)
|
||
|
|
and isinstance(n.value, str)
|
||
|
|
and "FROM " in n.value.upper()
|
||
|
|
]
|
||
|
|
self.assertTrue(requetes)
|
||
|
|
for requete in requetes:
|
||
|
|
self.assertTrue(
|
||
|
|
requete.upper().lstrip().startswith("SELECT"), requete[:40]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|