This commit is contained in:
2026-03-04 19:33:47 +03:00
parent 42def69dcc
commit 2820e491d5
28 changed files with 972 additions and 365 deletions

View File

@@ -2,7 +2,8 @@ import sys
from pathlib import Path
import shutil
import pytest
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from git.exc import InvalidGitRepositoryError
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -39,3 +40,76 @@ def test_superset_client_import_dashboard_guard():
client = SupersetClient(mock_env)
with pytest.raises(ValueError, match="file_name cannot be None"):
client.import_dashboard(None)
def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo():
"""Verify init_repo reclones when target path exists but is not a valid Git repository."""
service = GitService(base_path="test_repos_invalid_repo")
target_path = Path(service.base_path) / "covid"
target_path.mkdir(parents=True, exist_ok=True)
(target_path / "placeholder.txt").write_text("not a git repo", encoding="utf-8")
clone_result = MagicMock()
with patch("src.services.git_service.Repo") as repo_ctor:
repo_ctor.side_effect = InvalidGitRepositoryError("invalid repo")
repo_ctor.clone_from.return_value = clone_result
result = service.init_repo(10, "https://example.com/org/repo.git", "token", repo_key="covid")
assert result is clone_result
repo_ctor.assert_called_once_with(str(target_path))
repo_ctor.clone_from.assert_called_once()
assert not target_path.exists()
def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults():
"""Verify _ensure_gitflow_branches creates dev/preprod locally and pushes them to origin."""
service = GitService(base_path="test_repos_gitflow_defaults")
class FakeRemoteRef:
def __init__(self, remote_head):
self.remote_head = remote_head
class FakeHead:
def __init__(self, name, commit):
self.name = name
self.commit = commit
class FakeOrigin:
def __init__(self):
self.refs = [FakeRemoteRef("main")]
self.pushed = []
def fetch(self):
return []
def push(self, refspec=None):
self.pushed.append(refspec)
return []
class FakeHeadPointer:
def __init__(self, commit):
self.commit = commit
class FakeRepo:
def __init__(self):
self.head = FakeHeadPointer("main-commit")
self.heads = [FakeHead("main", "main-commit")]
self.origin = FakeOrigin()
def create_head(self, name, commit):
head = FakeHead(name, commit)
self.heads.append(head)
return head
def remote(self, name="origin"):
if name != "origin":
raise ValueError("unknown remote")
return self.origin
repo = FakeRepo()
service._ensure_gitflow_branches(repo, dashboard_id=10)
local_branch_names = {head.name for head in repo.heads}
assert {"main", "dev", "preprod"}.issubset(local_branch_names)
assert "dev:dev" in repo.origin.pushed
assert "preprod:preprod" in repo.origin.pushed

View File

@@ -11,6 +11,7 @@ import sys
from pathlib import Path
from fastapi import HTTPException
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -64,4 +65,40 @@ def test_create_gitea_pull_request_retries_with_remote_host_on_404(monkeypatch):
assert calls[1][1] == "https://giteabusya.bebesh.ru"
# [/DEF:test_create_gitea_pull_request_retries_with_remote_host_on_404:Function]
# [DEF:test_create_gitea_pull_request_returns_branch_error_when_target_missing:Function]
# @PURPOSE: Ensure Gitea 404 on PR creation is mapped to actionable target-branch validation error.
# @PRE: PR create call returns 404 and target branch is absent.
# @POST: Service raises HTTPException 400 with explicit missing target branch message.
def test_create_gitea_pull_request_returns_branch_error_when_target_missing(monkeypatch):
service = GitService(base_path="test_repos")
async def fake_gitea_request(method, server_url, pat, endpoint, payload=None):
if method == "POST" and endpoint.endswith("/pulls"):
raise HTTPException(status_code=404, detail="Gitea API error: The target couldn't be found.")
if method == "GET" and endpoint.endswith("/branches/dev"):
return {"name": "dev"}
if method == "GET" and endpoint.endswith("/branches/preprod"):
raise HTTPException(status_code=404, detail="branch not found")
raise AssertionError(f"Unexpected request: {method} {endpoint}")
monkeypatch.setattr(service, "_gitea_request", fake_gitea_request)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
service.create_gitea_pull_request(
server_url="https://gitea.bebesh.ru",
pat="secret",
remote_url="https://gitea.bebesh.ru/busya/covid-vaccine-dashboard.git",
from_branch="dev",
to_branch="preprod",
title="Promote dev -> preprod",
description="",
)
)
assert exc_info.value.status_code == 400
assert "target branch 'preprod'" in str(exc_info.value.detail)
# [/DEF:test_create_gitea_pull_request_returns_branch_error_when_target_missing:Function]
# [/DEF:backend.tests.core.test_git_service_gitea_pr:Module]

View File

@@ -0,0 +1,163 @@
# [DEF:backend.tests.scripts.test_clean_release_tui:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, tui, clean-release, curses
# @PURPOSE: Unit tests for the interactive curses TUI of the clean release process.
# @LAYER: Scripts
# @RELATION: TESTS -> backend.src.scripts.clean_release_tui
# @INVARIANT: TUI initializes, handles hotkeys (F5, F10) and safely falls back without TTY.
import os
import sys
import curses
from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
from backend.src.scripts.clean_release_tui import CleanReleaseTUI, main, tui_main
from backend.src.models.clean_release import CheckFinalStatus
@pytest.fixture
def mock_stdscr() -> MagicMock:
stdscr = MagicMock()
stdscr.getmaxyx.return_value = (40, 100)
stdscr.getch.return_value = -1
return stdscr
def test_headless_fallback(capsys):
"""
@TEST_EDGE: stdout_unavailable
Tests that if the stream is not a TTY or PYTEST_CURRENT_TEST is set,
the script falls back to a simple stdout print instead of trapping in curses.wrapper.
"""
# Environment should trigger headless fallback due to PYTEST_CURRENT_TEST being set
with mock.patch("backend.src.scripts.clean_release_tui.curses.wrapper") as curses_wrapper_mock:
with mock.patch("sys.stdout.isatty", return_value=False):
exit_code = main()
# Ensures wrapper wasn't used
curses_wrapper_mock.assert_not_called()
# Verify it still exits 0
assert exit_code == 0
# Verify headless info is printed
captured = capsys.readouterr()
assert "Enterprise Clean Release Validator (Headless Mode)" in captured.out
assert "FINAL STATUS: READY" in captured.out
@patch("backend.src.scripts.clean_release_tui.curses")
def test_tui_initial_render(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates the initial rendering cycle of the TUI application to ensure
titles, headers, footers and the READY state are drawn appropriately.
"""
# Ensure constants match
mock_curses_module.KEY_F10 = curses.KEY_F10
mock_curses_module.KEY_F5 = curses.KEY_F5
mock_curses_module.color_pair.side_effect = lambda x: x
mock_curses_module.A_BOLD = 0
app = CleanReleaseTUI(mock_stdscr)
assert app.status == "READY"
# We only want to run one loop iteration, so we mock getch to return F10
mock_stdscr.getch.return_value = curses.KEY_F10
app.loop()
# Assert header was drawn
addstr_calls = mock_stdscr.addstr.call_args_list
assert any("Enterprise Clean Release Validator" in str(call) for call in addstr_calls)
assert any("Candidate: [2026.03.03-rc1]" in str(call) for call in addstr_calls)
# Assert checks list is shown
assert any("Data Purity" in str(call) for call in addstr_calls)
assert any("Internal Sources Only" in str(call) for call in addstr_calls)
# Assert footer is shown
assert any("F5 Run" in str(call) for call in addstr_calls)
@patch("backend.src.scripts.clean_release_tui.curses")
def test_tui_run_checks_f5(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F5 to transition into the RUNNING checks flow.
"""
# Ensure constants match
mock_curses_module.KEY_F10 = curses.KEY_F10
mock_curses_module.KEY_F5 = curses.KEY_F5
mock_curses_module.color_pair.side_effect = lambda x: x
mock_curses_module.A_BOLD = 0
app = CleanReleaseTUI(mock_stdscr)
# getch sequence:
# 1. First loop: F5 (triggers run_checks)
# 2. Next call after run_checks: F10 to exit
mock_stdscr.f5_pressed = False
def side_effect():
if not mock_stdscr.f5_pressed:
mock_stdscr.f5_pressed = True
return curses.KEY_F5
return curses.KEY_F10
mock_stdscr.getch.side_effect = side_effect
with mock.patch("time.sleep", return_value=None):
app.loop()
# After F5 is pressed, status should be BLOCKED due to deliberate 'test-data' violation
assert app.status == CheckFinalStatus.BLOCKED
assert app.report_id is not None
assert "CCR-" in app.report_id
assert len(app.violations_list) > 0
@patch("backend.src.scripts.clean_release_tui.curses")
def test_tui_exit_f10(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F10 to exit the application immediately without running checks.
"""
# Ensure constants match
mock_curses_module.KEY_F10 = curses.KEY_F10
app = CleanReleaseTUI(mock_stdscr)
mock_stdscr.getch.return_value = curses.KEY_F10
# loop() should return cleanly
app.loop()
assert app.status == "READY"
@patch("backend.src.scripts.clean_release_tui.curses")
def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock):
"""
Simulates pressing F7 to clear history.
"""
mock_curses_module.KEY_F10 = curses.KEY_F10
mock_curses_module.KEY_F7 = curses.KEY_F7
mock_curses_module.color_pair.side_effect = lambda x: x
mock_curses_module.A_BOLD = 0
app = CleanReleaseTUI(mock_stdscr)
app.status = CheckFinalStatus.BLOCKED
app.report_id = "SOME-REPORT"
# F7 then F10
mock_stdscr.getch.side_effect = [curses.KEY_F7, curses.KEY_F10]
app.loop()
assert app.status == "READY"
assert app.report_id is None
assert len(app.checks_progress) == 0
# [/DEF:backend.tests.scripts.test_clean_release_tui:Module]

View File

@@ -13,7 +13,7 @@ from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.models.mapping import Base
from src.models.mapping import Base, Environment
from src.models.task import TaskRecord
from src.core.task_manager.persistence import TaskPersistenceService
from src.core.task_manager.models import Task, TaskStatus, LogEntry
@@ -138,6 +138,7 @@ class TestTaskPersistenceService:
def setup_method(self):
session = self.TestSessionLocal()
session.query(TaskRecord).delete()
session.query(Environment).delete()
session.commit()
session.close()
# [/DEF:setup_method:Function]
@@ -402,5 +403,29 @@ class TestTaskPersistenceService:
assert record.params["name"] == "test"
# [/DEF:test_persist_task_with_datetime_in_params:Function]
# [DEF:test_persist_task_resolves_environment_slug_to_existing_id:Function]
# @PURPOSE: Ensure slug-like environment token resolves to environments.id before persisting task.
# @PRE: environments table contains env with name convertible to provided slug token.
# @POST: task_records.environment_id stores actual environments.id and does not violate FK.
def test_persist_task_resolves_environment_slug_to_existing_id(self):
session = self.TestSessionLocal()
env = Environment(id="env-uuid-1", name="SS DEV", url="https://example.local", credentials_id="cred-1")
session.add(env)
session.commit()
session.close()
task = self._make_task(params={"environment_id": "ss-dev"})
with self._patched():
self.service.persist_task(task)
session = self.TestSessionLocal()
record = session.query(TaskRecord).filter_by(id="test-uuid-1").first()
session.close()
assert record is not None
assert record.environment_id == "env-uuid-1"
# [/DEF:test_persist_task_resolves_environment_slug_to_existing_id:Function]
# [/DEF:TestTaskPersistenceService:Class]
# [/DEF:test_task_persistence:Module]