From 27a9f0aff868c0e94bea3e9aaca5074e5b0bdeb3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 17:38:35 +0300 Subject: [PATCH] chore: cover env, baked file and live git paths of _resolve_app_version --- tests/test_app_version_resolution.py | 218 +++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/test_app_version_resolution.py diff --git a/tests/test_app_version_resolution.py b/tests/test_app_version_resolution.py new file mode 100644 index 0000000..fcdb959 --- /dev/null +++ b/tests/test_app_version_resolution.py @@ -0,0 +1,218 @@ +"""Tests for ``_resolve_app_version`` — the source of the admin sidebar footer. + +The admin sidebar shows ``{appVersion}`` next to a "remnawave-minishop" GitHub +link. That string is rendered by +``backend/bot/app/web/webapp/assets.py::_resolve_app_version`` through the +following precedence chain: + + 1. ``REMNAWAVE_MINISHOP_VERSION`` env var — manual override; + 2. ``$APP_ROOT/.build-version`` file — baked at Docker build time by the + ``version-builder`` stage in ``deploy/docker/Dockerfile`` (consumes .git + in a throwaway stage and ships only this one tiny file); + 3. live ``git describe`` / ``rev-parse`` — works in local dev where .git + is present; + 4. ``"dev+unknown"`` as the ultimate fallback. + +Before the build-time bake, the admin footer in production silently fell back +to step 4 because the Docker image carries no .git tree and no git binary. +""" + +import importlib +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +# Importing the webapp facade populates the runtime helpers we need. +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.webapp import assets as assets_module + + +def _reset_cache() -> None: + # The resolver memoizes the first result in a module-level global. + assets_module._APP_VERSION_CACHE = None # type: ignore[attr-defined] + # Some callers reach through the facade re-export — clear that too. + runtime = importlib.import_module("bot.app.web.webapp._runtime") + runtime._APP_VERSION_CACHE = None # type: ignore[attr-defined] + + +def _resolve(): + return assets_module._resolve_app_version() + + +class EnvOverrideTests(unittest.TestCase): + def setUp(self) -> None: + _reset_cache() + self.addCleanup(_reset_cache) + + def test_env_var_short_circuits_everything(self): + # No git lookups, no file IO when the operator provided a value. + called = {"git": 0} + + def fake_git(*args): + called["git"] += 1 + return "should-not-be-used" + + with ( + patch.dict(os.environ, {"REMNAWAVE_MINISHOP_VERSION": "v9.9.9-rc1"}), + patch.object(assets_module, "_run_git_command", fake_git), + ): + self.assertEqual(_resolve(), "v9.9.9-rc1") + self.assertEqual(called["git"], 0) + + def test_blank_env_var_falls_through(self): + env = {"REMNAWAVE_MINISHOP_VERSION": " "} + with ( + patch.dict(os.environ, env), + patch.object(assets_module, "_run_git_command", lambda *a: ""), + ): + # No env, no file, no git → fallback string. + self.assertEqual(_resolve(), "dev+unknown") + + +class BuildVersionFileTests(unittest.TestCase): + """``$APP_ROOT/.build-version`` is what the Docker image ships.""" + + def setUp(self) -> None: + _reset_cache() + self.addCleanup(_reset_cache) + + def test_reads_baked_version_file(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / ".build-version").write_text("v3.4.5+12.gabcdef1", encoding="utf-8") + env = dict(os.environ) + env.pop("REMNAWAVE_MINISHOP_VERSION", None) + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + # ensure live git doesn't accidentally win if file read fails: + patch.object(assets_module, "_run_git_command", lambda *a: ""), + ): + self.assertEqual(_resolve(), "v3.4.5+12.gabcdef1") + + def test_strips_trailing_whitespace_and_newlines_in_file(self): + # Shell ``printf '%s'`` writes no newline, but earlier helpers used + # ``echo`` which appends one. Both must produce the same result. + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / ".build-version").write_text("v1.2.3\n\n", encoding="utf-8") + env = dict(os.environ) + env.pop("REMNAWAVE_MINISHOP_VERSION", None) + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", lambda *a: ""), + ): + self.assertEqual(_resolve(), "v1.2.3") + + def test_empty_file_falls_through_to_git_then_unknown(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / ".build-version").write_text("", encoding="utf-8") + env = dict(os.environ) + env.pop("REMNAWAVE_MINISHOP_VERSION", None) + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", lambda *a: ""), + ): + # No env, empty file, no live git → ultimate fallback. + self.assertEqual(_resolve(), "dev+unknown") + + +class LiveGitFallbackTests(unittest.TestCase): + """Local dev path: no env, no baked file, but ``git`` works.""" + + def setUp(self) -> None: + _reset_cache() + self.addCleanup(_reset_cache) + + def _run_with_git(self, replies: dict, *, dirty: bool = False) -> str: + # Map (subcommand, *args) tuples to canned stdout values. + def fake_git(*args): + return replies.get(args, "") + + with tempfile.TemporaryDirectory() as tmp: + # ``.build-version`` deliberately absent so we fall through. + env = dict(os.environ) + env.pop("REMNAWAVE_MINISHOP_VERSION", None) + base = { + ("describe", "--tags", "--abbrev=0"): replies.get("tag", ""), + ("rev-parse", "--short", "HEAD"): replies.get("sha", ""), + ("status", "--porcelain"): "M file\n" if dirty else "", + ("rev-list", f"{replies.get('tag', '')}..HEAD", "--count"): replies.get( + "commits_since_tag", "0" + ), + } + + def real_fake_git(*args): + return base.get(args, "") + + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", real_fake_git), + ): + return _resolve() + + def test_tag_with_zero_commits_since_returns_bare_tag(self): + result = self._run_with_git( + {"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"} + ) + self.assertEqual(result, "v2.0.0") + + def test_tag_plus_distance_plus_sha_format(self): + result = self._run_with_git( + {"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"} + ) + self.assertEqual(result, "v2.0.0+7.gabcdef1") + + def test_sha_only_when_no_tag(self): + result = self._run_with_git({"sha": "abcdef1"}) + self.assertEqual(result, "dev+gabcdef1") + + def test_neither_tag_nor_sha_is_unknown(self): + result = self._run_with_git({}) + self.assertEqual(result, "dev+unknown") + + def test_dirty_suffix_is_appended(self): + result = self._run_with_git( + {"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}, + dirty=True, + ) + self.assertEqual(result, "v2.0.0-dirty") + + +class CacheBehaviourTests(unittest.TestCase): + def setUp(self) -> None: + _reset_cache() + self.addCleanup(_reset_cache) + + def test_second_call_is_cached_and_does_not_reread(self): + calls = {"git": 0} + + def fake_git(*args): + calls["git"] += 1 + return "abcdef1" if args == ("rev-parse", "--short", "HEAD") else "" + + with tempfile.TemporaryDirectory() as tmp: + env = dict(os.environ) + env.pop("REMNAWAVE_MINISHOP_VERSION", None) + with ( + patch.dict(os.environ, env, clear=True), + patch.object(assets_module, "APP_ROOT", Path(tmp)), + patch.object(assets_module, "_run_git_command", fake_git), + ): + first = _resolve() + first_calls = calls["git"] + second = _resolve() + second_calls = calls["git"] + + self.assertEqual(first, "dev+gabcdef1") + self.assertEqual(second, first) + # No additional git invocations after the cache was populated. + self.assertGreater(first_calls, 0) + self.assertEqual(first_calls, second_calls) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()