test_updates_api.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. """Integration tests for Updates API endpoints."""
  2. from pathlib import Path
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestUpdatesAPI:
  7. @pytest.mark.asyncio
  8. async def test_get_version(self, async_client: AsyncClient):
  9. response = await async_client.get("/api/v1/updates/version")
  10. assert response.status_code == 200
  11. @pytest.mark.asyncio
  12. async def test_apply_update_docker_rejection(self, async_client: AsyncClient):
  13. with patch("backend.app.api.routes.updates._is_docker_environment", return_value=True):
  14. response = await async_client.post("/api/v1/updates/apply")
  15. result = response.json()
  16. assert result["success"] is False
  17. assert result["is_docker"] is True
  18. @pytest.mark.asyncio
  19. async def test_apply_update_non_docker(self, async_client: AsyncClient):
  20. """Test non-Docker path - mock _perform_update to prevent side effects."""
  21. with (
  22. patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
  23. patch("backend.app.api.routes.updates._perform_update", new_callable=AsyncMock),
  24. ):
  25. response = await async_client.post("/api/v1/updates/apply")
  26. assert response.json()["success"] is True
  27. def test_is_docker_with_dockerenv(self):
  28. from backend.app.api.routes.updates import _is_docker_environment
  29. with patch("os.path.exists", return_value=True):
  30. assert _is_docker_environment() is True
  31. def test_parse_version(self):
  32. from backend.app.api.routes.updates import parse_version
  33. assert parse_version("0.1.5")[:3] == (0, 1, 5)
  34. def test_is_newer_version(self):
  35. from backend.app.api.routes.updates import is_newer_version
  36. assert is_newer_version("0.1.5", "0.1.5b7") is True
  37. @pytest.mark.asyncio
  38. async def test_perform_update_runs_pip_in_app_dir_not_data_dir(self, tmp_path):
  39. """Native install: `requirements.txt` lives at INSTALL_PATH (the source-
  40. code dir), NOT at DATA_DIR (where systemd sets DATA_DIR=INSTALL_PATH/data).
  41. Pre-fix, the updater ran `pip install -r requirements.txt` with
  42. `cwd=settings.base_dir`, which on a native install resolves to the data
  43. dir — `requirements.txt` isn't there and pip fails with `Could not open
  44. requirements file`. The fix: pip's cwd is `settings.app_dir` (the source
  45. tree) so it can actually find the file.
  46. This test mocks every subprocess so it can capture the cwd of each call
  47. and assert that the pip step runs in app_dir while git steps continue
  48. to run in base_dir (their existing behaviour — git walks up to find
  49. `.git` so that path keeps working)."""
  50. from backend.app.api.routes import updates as updates_module
  51. # Set up fake install layout: app_dir has requirements.txt, data_dir is
  52. # a sibling (mirroring `INSTALL_PATH=/opt/bambuddy`, `DATA_DIR=/opt/bambuddy/data`).
  53. app_dir = tmp_path / "app"
  54. data_dir = tmp_path / "app" / "data"
  55. app_dir.mkdir()
  56. data_dir.mkdir()
  57. (app_dir / "requirements.txt").write_text("fastapi\n")
  58. # Capture every subprocess call's cwd + the executable token.
  59. calls: list[dict] = []
  60. async def fake_create_subprocess_exec(*args, **kwargs):
  61. calls.append({"args": args, "cwd": kwargs.get("cwd")})
  62. proc = MagicMock()
  63. proc.communicate = AsyncMock(return_value=(b"", b""))
  64. proc.returncode = 0
  65. return proc
  66. with (
  67. patch.object(updates_module.settings, "base_dir", data_dir),
  68. patch.object(updates_module.settings, "app_dir", app_dir),
  69. patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
  70. patch.object(
  71. updates_module.asyncio,
  72. "create_subprocess_exec",
  73. side_effect=fake_create_subprocess_exec,
  74. ),
  75. ):
  76. await updates_module._perform_update()
  77. # Find the pip invocation (sys.executable + "-m" + "pip" + "install").
  78. pip_calls = [c for c in calls if "pip" in c["args"] and "install" in c["args"]]
  79. assert pip_calls, "pip install was never invoked. Captured: " + repr([c["args"] for c in calls])
  80. pip_cwd = pip_calls[0]["cwd"]
  81. assert pip_cwd == str(app_dir), (
  82. f"pip install must run in app_dir ({app_dir}) so it finds "
  83. f"requirements.txt; got cwd={pip_cwd}. Regression to base_dir "
  84. f"breaks every native-install upgrade."
  85. )
  86. # Sanity check: the requirements.txt that pip would read actually exists
  87. # at the captured cwd. If this fails the cwd is wrong even if it isn't
  88. # base_dir — useful diagnostic if someone refactors path handling.
  89. assert (Path(pip_cwd) / "requirements.txt").exists()