clean ui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
163
backend/tests/scripts/test_clean_release_tui.py
Normal file
163
backend/tests/scripts/test_clean_release_tui.py
Normal 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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user