"""Tests du versionnage ObsiGate (source unique de vérité : `VERSION`). Deux niveaux : 1. `backend/version.py` — résolution de la version exposée par /api/health. 2. `scripts/bump_version.py` — incrément SemVer, détection depuis le message de commit, synchronisation des fichiers dérivés et rotation du CHANGELOG. 3. Un test de cohérence globale : la version affichée doit être **la même** partout (VERSION, package.json, desktop Tauri, CHANGELOG, ROADMAP) — c'est le garde-fou contre la dérive qui a motivé BUG-047. """ from __future__ import annotations import importlib.util import json import re from datetime import date from pathlib import Path import pytest from backend import version as version_module REPO_ROOT = Path(__file__).resolve().parent.parent # ── scripts/bump_version.py (outil hors package) ─────────────────────────── def _load_bump_tool(): """Charge `scripts/bump_version.py` par chemin (scripts/ n'est pas un package).""" path = REPO_ROOT / "scripts" / "bump_version.py" spec = importlib.util.spec_from_file_location("obsigate_bump_version", path) module = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(module) return module bump_tool = _load_bump_tool() # ── backend/version.py ──────────────────────────────────────────────────── class TestVersionResolution: def test_clean_base(self): assert version_module._clean_base("v2.3.0") == "2.3.0" assert version_module._clean_base("2.3.0-12-gabc1234") == "2.3.0" assert version_module._clean_base("2.3.0-dev") == "2.3.0" assert version_module._clean_base(" v1.2.3-dirty ") == "1.2.3" assert version_module._clean_base("pas une version") == "" assert version_module._clean_base("") == "" def test_root_version_file_wins(self, monkeypatch, tmp_path): root_version = tmp_path / "VERSION" root_version.write_text("2.3.0\n", encoding="utf-8") legacy = tmp_path / "backend" / "VERSION" legacy.parent.mkdir() legacy.write_text("1.9.9\n", encoding="utf-8") monkeypatch.delenv("OBSIGATE_VERSION", raising=False) monkeypatch.setattr(version_module, "VERSION_FILE", root_version) monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", legacy) assert version_module.get_version() == "2.3.0" assert version_module.get_version_tuple() == (2, 3, 0) def test_env_override_wins(self, monkeypatch, tmp_path): root_version = tmp_path / "VERSION" root_version.write_text("2.3.0\n", encoding="utf-8") monkeypatch.setattr(version_module, "VERSION_FILE", root_version) monkeypatch.setenv("OBSIGATE_VERSION", "v9.9.9-dirty") assert version_module.get_version() == "9.9.9" def test_falls_back_to_legacy_then_zero(self, monkeypatch, tmp_path): missing = tmp_path / "VERSION" legacy = tmp_path / "backend" / "VERSION" legacy.parent.mkdir() legacy.write_text("1.8.0-35-g314e37c\n", encoding="utf-8") monkeypatch.delenv("OBSIGATE_VERSION", raising=False) monkeypatch.setattr(version_module, "VERSION_FILE", missing) monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", legacy) assert version_module.get_version() == "1.8.0" legacy.unlink() monkeypatch.setattr(version_module, "_run_git", lambda args: "") assert version_module.get_version() == "0.0.0" def test_invalid_version_file_is_ignored(self, monkeypatch, tmp_path): broken = tmp_path / "VERSION" broken.write_text("dev\n", encoding="utf-8") monkeypatch.delenv("OBSIGATE_VERSION", raising=False) monkeypatch.setattr(version_module, "VERSION_FILE", broken) monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", tmp_path / "absent") monkeypatch.setattr(version_module, "_run_git", lambda args: "") assert version_module.get_version() == "0.0.0" def test_no_dev_suffix_ever_returned(self, monkeypatch, tmp_path): """Le numéro affiché ne doit jamais contenir -dev / -N-gHASH.""" root_version = tmp_path / "VERSION" root_version.write_text("2.3.0-12-gabc1234\n", encoding="utf-8") monkeypatch.delenv("OBSIGATE_VERSION", raising=False) monkeypatch.setattr(version_module, "VERSION_FILE", root_version) assert version_module.get_version() == "2.3.0" # ── Incrément SemVer ────────────────────────────────────────────────────── class TestBumpMath: def test_major_resets_minor_and_patch(self): assert bump_tool.bump_version((2, 3, 4), "major") == (3, 0, 0) def test_minor_resets_patch(self): assert bump_tool.bump_version((2, 3, 4), "minor") == (2, 4, 0) def test_patch(self): assert bump_tool.bump_version((2, 3, 4), "patch") == (2, 3, 5) def test_unknown_kind_raises(self): with pytest.raises(ValueError): bump_tool.bump_version((2, 3, 4), "banana") @pytest.mark.parametrize( ("raw", "expected"), [ ("v2.3.0", (2, 3, 0)), ("2.3.0-dev", (2, 3, 0)), ("2.2.1-35-g314e37c", (2, 2, 1)), ("n'importe quoi", None), ], ) def test_parse_version(self, raw, expected): assert bump_tool.parse_version(raw) == expected def test_format_version(self): assert bump_tool.format_version((2, 3, 0)) == "2.3.0" # ── Détection depuis le message de commit ───────────────────────────────── class TestDetectBumpFromMessage: @pytest.mark.parametrize( ("message", "expected"), [ ("feat(editeur): edition inline", "minor"), ("feature: x", "minor"), ("fix(ai): erreur lisible", "patch"), ("perf: rapide", "patch"), ("docs: readme", "patch"), ("chore: menage", "patch"), ("refactor: nettoyage", "patch"), ("feat: x\n\nBREAKING CHANGE: api", "major"), ("feat(api)!: rupture", "major"), ("fix!: rupture", "major"), ], ) def test_types(self, message, expected): assert bump_tool.detect_bump_from_message(message) == expected @pytest.mark.parametrize( "message", [ "", " \n", "# commentaire de git seulement\n", "Merge branch 'main' into dev", "Revert \"feat: x\"", "chore(release): v2.3.0", ], ) def test_no_bump(self, message): assert bump_tool.detect_bump_from_message(message) is None def test_commit_message_comment_is_ignored(self): message = "fix: correction\n# Please enter the commit message\n" assert bump_tool.detect_bump_from_message(message) == "patch" def test_empty_message_uses_default_kind(self): """Commit rédigé dans un éditeur : incrément différé, message pas encore saisi.""" template = "\n# Please enter the commit message for your changes. Lines starting\n# with '#'\n" assert bump_tool.detect_bump_from_message(template) is None assert bump_tool.detect_bump_from_message(template, default="patch") == "patch" assert bump_tool.detect_bump_from_message("", default="patch") == "patch" # Un vrai message garde la priorité sur le défaut. assert bump_tool.detect_bump_from_message("feat: x", default="patch") == "minor" # ── Synchronisation des fichiers dérivés ────────────────────────────────── @pytest.fixture def fake_repo(tmp_path: Path) -> Path: """Dépôt minimal avec tous les fichiers dérivés du versionnage.""" (tmp_path / "docs").mkdir() (tmp_path / "desktop").mkdir() (tmp_path / "scripts").mkdir() (tmp_path / "VERSION").write_text("2.2.1\n", encoding="utf-8") (tmp_path / "package.json").write_text( json.dumps({"name": "obsigate", "version": "1.0.0", "type": "commonjs"}, indent=2) + "\n", encoding="utf-8", ) (tmp_path / "desktop" / "tauri.conf.json").write_text( json.dumps({"productName": "ObsiGate", "version": "2.0.0"}, indent=2) + "\n", encoding="utf-8", ) (tmp_path / "desktop" / "Cargo.toml").write_text( '[package]\nname = "obsigate-desktop"\nversion = "2.0.0"\nedition = "2021"\n', encoding="utf-8", ) (tmp_path / "desktop" / "Cargo.lock").write_text( '[[package]]\nname = "autre"\nversion = "1.0.0"\n\n' '[[package]]\nname = "obsigate-desktop"\nversion = "2.0.0"\n', encoding="utf-8", ) (tmp_path / "docs" / "ROADMAP.md").write_text( "# ObsiGate — Roadmap\n\n" "> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-15\n", encoding="utf-8", ) (tmp_path / "CHANGELOG.md").write_text( "# Changelog — ObsiGate\n\n" "> **En cours de développement** : les changements non publiés sont dans la section\n" "> [Unreleased](#unreleased). La dernière version publiée est **2.2.1**.\n\n" "---\n\n" "## [Unreleased]\n\n" "### Corrigé\n\n" "- **BUG-047 — version** : correctif.\n\n" "---\n\n" "## [2.2.1] — 2026-09-11\n\n" "### Corrigé\n\n" "- ancien\n", encoding="utf-8", ) return tmp_path class TestSyncDerivedFiles: def test_apply_version_writes_everything(self, fake_repo): touched = bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16)) assert (fake_repo / "VERSION").read_text(encoding="utf-8").strip() == "2.3.0" assert json.loads((fake_repo / "package.json").read_text(encoding="utf-8"))["version"] == "2.3.0" assert ( json.loads((fake_repo / "desktop" / "tauri.conf.json").read_text(encoding="utf-8"))[ "version" ] == "2.3.0" ) cargo = (fake_repo / "desktop" / "Cargo.toml").read_text(encoding="utf-8") assert 'version = "2.3.0"' in cargo lock = (fake_repo / "desktop" / "Cargo.lock").read_text(encoding="utf-8") assert 'name = "autre"\nversion = "1.0.0"' in lock # bloc non touché assert re.search(r'name = "obsigate-desktop"\nversion = "2\.3\.0"', lock) assert "2.3.0" in (fake_repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8") assert "-dev" not in (fake_repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8") assert set(touched) == { "VERSION", "package.json", "desktop/tauri.conf.json", "desktop/Cargo.toml", "desktop/Cargo.lock", "docs/ROADMAP.md", "CHANGELOG.md", } def test_changelog_rotation(self, fake_repo): bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16)) text = (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8") # La nouvelle section vide est en tête, la version livrée juste après. assert re.search( r"## \[Unreleased\]\n\n---\n\n## \[2\.3\.0\] — 2026-09-16\n", text ), text # Le contenu précédemment « Unreleased » appartient désormais à 2.3.0. assert text.index("## [2.3.0] — 2026-09-16") < text.index("- **BUG-047 — version**") # La phrase d'en-tête suit la version livrée. assert "La dernière version livrée est **2.3.0**" in text assert "La dernière version publiée est **2.2.1**" not in text def test_changelog_rotation_is_idempotent(self, fake_repo): bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16)) once = (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8") assert bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16)) is False assert (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8") == once def test_apply_version_is_idempotent(self, fake_repo): bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16)) assert bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16)) == [] def test_crlf_is_preserved(self, fake_repo): path = fake_repo / "VERSION" path.write_bytes(b"2.2.1\r\n") bump_tool.write_preserving_newlines(path, "2.4.0\n") assert path.read_bytes() == b"2.4.0\r\n" def test_readmes_badges_and_footers(self, fake_repo): (fake_repo / "README.md").write_text( "[![Version](https://img.shields.io/badge/Version-1.7.0-blue.svg)]()\n" "...\n" "See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v1.7.0).\n" "*Project: ObsiGate | Version: 1.7.0 | Last updated: May 2026*\n", encoding="utf-8", ) (fake_repo / "README.fr.md").write_text( "[![Version](https://img.shields.io/badge/Version-2.0.0--dev-blue.svg)]()\n" "Consultez le [CHANGELOG.md](./CHANGELOG.md) (v1.0.0 → v2.0.0-dev).\n" "*Projet : ObsiGate | Version : 2.0.0-dev | Dernière mise à jour : Juin 2026*\n", encoding="utf-8", ) changed = bump_tool.sync_readmes(fake_repo, "2.3.0") assert sorted(changed) == ["README.fr.md", "README.md"] readme = (fake_repo / "README.md").read_text(encoding="utf-8") assert "badge/Version-2.3.0-blue.svg" in readme assert "| Version: 2.3.0 |" in readme assert "(v1.0.0 → v2.3.0)" in readme readme_fr = (fake_repo / "README.fr.md").read_text(encoding="utf-8") assert "badge/Version-2.3.0-blue.svg" in readme_fr assert "| Version : 2.3.0 |" in readme_fr assert "(v1.0.0 → v2.3.0)" in readme_fr # Idempotent assert bump_tool.sync_readmes(fake_repo, "2.3.0") == [] # ── Cohérence globale du dépôt (garde-fou anti-dérive) ──────────────────── class TestRepoVersionAlignment: def _repo_version(self) -> str: raw = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() assert re.fullmatch(r"\d+\.\d+\.\d+", raw), f"VERSION invalide : {raw!r}" return raw def test_version_file_is_semver(self): assert self._repo_version() def test_package_json_matches(self): data = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8")) assert data["version"] == self._repo_version() def test_desktop_files_match(self): version = self._repo_version() conf = json.loads((REPO_ROOT / "desktop" / "tauri.conf.json").read_text(encoding="utf-8")) assert conf["version"] == version cargo = (REPO_ROOT / "desktop" / "Cargo.toml").read_text(encoding="utf-8") assert re.search(rf'^version = "{re.escape(version)}"$', cargo, re.M), cargo[:200] lock = (REPO_ROOT / "desktop" / "Cargo.lock").read_text(encoding="utf-8") assert re.search(rf'name = "obsigate-desktop"\r?\nversion = "{re.escape(version)}"', lock) def test_changelog_has_a_section_for_the_version(self): version = self._repo_version() text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") assert re.search(rf"(?m)^## \[{re.escape(version)}\]", text), ( "CHANGELOG.md doit contenir une section pour la version livrée" ) def test_roadmap_header_matches(self): version = self._repo_version() text = (REPO_ROOT / "docs" / "ROADMAP.md").read_text(encoding="utf-8") assert re.search(rf"> \*\*Version :\*\* {re.escape(version)} ", text) def test_no_hardcoded_version_left_in_build_pipeline(self): """Dockerfile / compose / CI ne doivent plus coder de numéro de version.""" dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") assert "COPY VERSION ./VERSION" in dockerfile assert not re.search(r"ARG VERSION=\d+\.\d+\.\d+", dockerfile) compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert not re.search(r"VERSION:\s*\$\{VERSION:-[\d.]+\}", compose) ci = (REPO_ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") assert "git describe --tags --dirty" not in ci def test_hooks_are_versioned_and_documented(self): assert (REPO_ROOT / ".githooks" / "prepare-commit-msg").exists() assert (REPO_ROOT / ".githooks" / "post-commit").exists() assert (REPO_ROOT / "scripts" / "install-hooks.sh").exists() # post-commit rattache les fichiers au commit (amend) puis tague. post = (REPO_ROOT / ".githooks" / "post-commit").read_text(encoding="utf-8") assert "--amend" in post assert "git tag -a" in post assert "rebase-merge" in post # garde : jamais d'amend en rebase/merge # prepare-commit-msg incrémente et laisse la liste des fichiers au post-commit. prep = (REPO_ROOT / ".githooks" / "prepare-commit-msg").read_text(encoding="utf-8") assert "--from-message-file" in prep assert "obsigate-version-files" in prep