git list refactor

This commit is contained in:
2026-03-01 12:13:19 +03:00
parent 5d45b4adb0
commit f24200d52a
26 changed files with 10313 additions and 2179 deletions

View File

@@ -76,11 +76,15 @@ class _FakeTaskManager:
class _FakeConfigManager:
def get_environments(self):
return [
SimpleNamespace(id="dev", name="Development"),
SimpleNamespace(id="prod", name="Production"),
SimpleNamespace(id="dev", name="Development", url="http://dev", credentials_id="dev", username="fakeuser", password="fakepassword"),
SimpleNamespace(id="prod", name="Production", url="http://prod", credentials_id="prod", username="fakeuser", password="fakepassword"),
]
def get_config(self):
return SimpleNamespace(
settings=SimpleNamespace(migration_sync_cron="0 0 * * *"),
environments=self.get_environments()
)
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @TIER: TRIVIAL
@@ -645,5 +649,49 @@ def test_confirm_nonexistent_id_returns_404():
assert exc.value.status_code == 404
# [/DEF:test_guarded_operation_confirm_roundtrip:Function]
# [DEF:test_migration_with_dry_run_includes_summary:Function]
# @PURPOSE: Migration command with dry run flag must return the dry run summary in confirmation text.
# @PRE: user specifies a migration with --dry-run flag.
# @POST: Response state is needs_confirmation and text contains dry-run summary counts.
def test_migration_with_dry_run_includes_summary(monkeypatch):
import src.core.migration.dry_run_orchestrator as dry_run_module
from unittest.mock import MagicMock
_clear_assistant_state()
task_manager = _FakeTaskManager()
db = _FakeDb()
class _FakeDryRunService:
def run(self, selection, source_client, target_client, db_session):
return {
"summary": {
"dashboards": {"create": 1, "update": 0, "delete": 0},
"charts": {"create": 3, "update": 2, "delete": 1},
"datasets": {"create": 0, "update": 1, "delete": 0}
}
}
monkeypatch.setattr(dry_run_module, "MigrationDryRunService", _FakeDryRunService)
import src.core.superset_client as superset_client_module
monkeypatch.setattr(superset_client_module, "SupersetClient", lambda env: MagicMock())
start = _run_async(
assistant_module.send_message(
request=assistant_module.AssistantMessageRequest(
message="миграция с dev на prod для дашборда 10 --dry-run"
),
current_user=_admin_user(),
task_manager=task_manager,
config_manager=_FakeConfigManager(),
db=db,
)
)
assert start.state == "needs_confirmation"
assert "отчет dry-run: ВКЛ" in start.text
assert "Отчет dry-run:" in start.text
assert "создано новых объектов: 4" in start.text
assert "обновлено: 3" in start.text
assert "удалено: 1" in start.text
# [/DEF:test_migration_with_dry_run_includes_summary:Function]
# [/DEF:backend.src.api.routes.__tests__.test_assistant_api:Module]

View File

@@ -0,0 +1,198 @@
# [DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]
# @TIER: STANDARD
# @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests)
# @RELATION: CALLS -> src.api.routes.git.get_repository_status
from fastapi import HTTPException
import pytest
import asyncio
from src.api.routes import git as git_routes
# [DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# @PURPOSE: Ensure missing local repository is represented as NO_REPO payload instead of an API error.
# @PRE: GitService.get_status raises HTTPException(404).
# @POST: Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/missing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise AssertionError("get_status must not be called when repository path is missing")
monkeypatch.setattr(git_routes, "git_service", MissingRepoGitService())
response = asyncio.run(git_routes.get_repository_status(34))
assert response["sync_status"] == "NO_REPO"
assert response["sync_state"] == "NO_REPO"
assert response["has_repo"] is False
assert response["current_branch"] is None
# [/DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# [DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# @PURPOSE: Ensure HTTP exceptions other than 404 are not masked.
# @PRE: GitService.get_status raises HTTPException with non-404 status.
# @POST: Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/existing-repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise HTTPException(status_code=409, detail="Conflict")
monkeypatch.setattr(git_routes, "git_service", ConflictGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_status(34))
assert exc_info.value.status_code == 409
assert exc_info.value.detail == "Conflict"
# [/DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE: GitService.get_diff raises HTTPException.
# @POST: Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
raise HTTPException(status_code=404, detail="Repository missing")
monkeypatch.setattr(git_routes, "git_service", DiffGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_repository_diff(12))
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Repository missing"
# [/DEF:test_get_repository_diff_propagates_http_exception:Function]
# [DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# @PURPOSE: Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
# @PRE: GitService.get_commit_history raises ValueError.
# @POST: Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50):
raise ValueError("broken parser")
monkeypatch.setattr(git_routes, "git_service", HistoryGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.get_history(12))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "get_history failed: broken parser"
# [/DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# [DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# @PURPOSE: Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE: GitService.commit_changes raises RuntimeError.
# @POST: Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files):
raise RuntimeError("index lock")
class CommitPayload:
message = "test"
files = ["dashboards/a.yaml"]
monkeypatch.setattr(git_routes, "git_service", CommitGitService())
with pytest.raises(HTTPException) as exc_info:
asyncio.run(git_routes.commit_changes(12, CommitPayload()))
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "commit_changes failed: index lock"
# [/DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# [DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# @PURPOSE: Ensure batch endpoint returns per-dashboard statuses in one response.
# @PRE: Some repositories are missing and some are initialized.
# @POST: Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
if dashboard_id == 2:
return {"sync_state": "SYNCED", "sync_status": "OK"}
raise HTTPException(status_code=404, detail="not found")
monkeypatch.setattr(git_routes, "git_service", BatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda path: path.endswith("/repo-2"))
class BatchRequest:
dashboard_ids = [1, 2]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["1"]["sync_status"] == "NO_REPO"
assert response.statuses["2"]["sync_state"] == "SYNCED"
# [/DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# [DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# @PURPOSE: Ensure batch endpoint marks failed items as ERROR without failing entire request.
# @PRE: GitService raises non-HTTP exception for one dashboard.
# @POST: Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
raise RuntimeError("boom")
monkeypatch.setattr(git_routes, "git_service", BatchErrorGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [9]
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert response.statuses["9"]["sync_status"] == "ERROR"
assert response.statuses["9"]["sync_state"] == "ERROR"
# [/DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
# @PRE: request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
# @POST: Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
return f"/tmp/repo-{dashboard_id}"
def get_status(self, dashboard_id: int) -> dict:
return {"sync_state": "SYNCED", "sync_status": "OK"}
monkeypatch.setattr(git_routes, "git_service", SafeBatchGitService())
monkeypatch.setattr(git_routes.os.path, "exists", lambda _path: True)
class BatchRequest:
dashboard_ids = [1, 1] + list(range(2, 90))
response = asyncio.run(git_routes.get_repository_status_batch(BatchRequest()))
assert len(response.statuses) == git_routes.MAX_REPOSITORY_STATUS_BATCH
assert "1" in response.statuses
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module]