Backend (Phase 1-6): - Pydantic schemas for request/response validation - Service layer (SSH Key, Server, Repo, Sync) - API routes with authentication - FastAPI main application with lifespan management - ORM models (SshKey, Server, Repo, SyncLog) Frontend (Phase 7): - Vue 3 + Element Plus + Pinia + Vue Router - API client with Axios and interceptors - State management stores - All page components (Dashboard, Servers, Repos, SyncLogs, SshKeys, Settings) Deployment (Phase 8): - README with quick start guide - Startup script (start.sh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""
|
|
Tests for Sync Service.
|
|
"""
|
|
import base64
|
|
import pytest
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch, MagicMock, call
|
|
from app.models.repo import Repo
|
|
from app.models.server import Server
|
|
from app.models.ssh_key import SshKey
|
|
from app.services.sync_service import SyncService
|
|
|
|
|
|
# Valid test SSH key
|
|
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
|
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk
|
|
AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA
|
|
AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E
|
|
JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ=
|
|
-----END OPENSSH PRIVATE KEY-----
|
|
"""
|
|
|
|
|
|
def create_test_server(db_session, name="test-server"):
|
|
"""Helper to create a test server."""
|
|
from app.services.ssh_key_service import SshKeyService
|
|
from app.services.server_service import ServerService
|
|
|
|
ssh_service = SshKeyService(db_session)
|
|
ssh_key = ssh_service.create_ssh_key(name="test-ssh-key", private_key=VALID_SSH_KEY)
|
|
|
|
server_service = ServerService(db_session)
|
|
return server_service.create_server(
|
|
name=name,
|
|
url="https://gitea.example.com",
|
|
api_token="test-api-token",
|
|
ssh_key_id=ssh_key.id,
|
|
sync_enabled=False,
|
|
schedule_cron=None
|
|
)
|
|
|
|
|
|
def create_test_repo(db_session, server_id=None, name="test-repo", local_path=None):
|
|
"""Helper to create a test repo."""
|
|
if server_id is None:
|
|
server = create_test_server(db_session)
|
|
server_id = server.id
|
|
|
|
import time
|
|
repo = Repo(
|
|
server_id=server_id,
|
|
name=name,
|
|
full_name=f"owner/{name}",
|
|
clone_url="git@gitea.example.com:owner/test-repo.git",
|
|
local_path=local_path or "/tmp/test-repo",
|
|
status="pending",
|
|
created_at=int(time.time())
|
|
)
|
|
db_session.add(repo)
|
|
db_session.commit()
|
|
db_session.refresh(repo)
|
|
return repo
|
|
|
|
|
|
class TestSyncRepo:
|
|
"""Tests for sync_repo method."""
|
|
|
|
def test_sync_repo_clones_new_repo(self, db_session, test_env_vars, tmp_path):
|
|
"""Test syncing a new repository triggers clone."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
|
|
service = SyncService(db_session)
|
|
|
|
with patch.object(service, '_clone_repo') as mock_clone:
|
|
service.sync_repo(repo, VALID_SSH_KEY)
|
|
mock_clone.assert_called_once()
|
|
|
|
def test_sync_repo_fetches_existing_repo(self, db_session, test_env_vars, tmp_path):
|
|
"""Test syncing an existing repository triggers fetch."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
|
|
# Create the directory to simulate existing repo
|
|
tmp_path.joinpath("test-repo").mkdir()
|
|
|
|
service = SyncService(db_session)
|
|
|
|
with patch.object(service, '_fetch_repo') as mock_fetch:
|
|
with patch('pathlib.Path.exists', return_value=True):
|
|
service.sync_repo(repo, VALID_SSH_KEY)
|
|
mock_fetch.assert_called_once()
|
|
|
|
def test_sync_repo_updates_status_to_syncing(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that sync_repo updates repo status to syncing."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
|
|
service = SyncService(db_session)
|
|
|
|
with patch.object(service, '_clone_repo'):
|
|
service.sync_repo(repo, VALID_SSH_KEY)
|
|
assert repo.status == "syncing"
|
|
|
|
def test_sync_repo_updates_last_sync_at(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that sync_repo updates last_sync_at timestamp."""
|
|
import time
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
|
|
service = SyncService(db_session)
|
|
|
|
with patch.object(service, '_clone_repo'):
|
|
before_sync = int(time.time())
|
|
service.sync_repo(repo, VALID_SSH_KEY)
|
|
after_sync = int(time.time())
|
|
|
|
assert before_sync <= repo.last_sync_at <= after_sync
|
|
|
|
|
|
class TestCloneRepo:
|
|
"""Tests for _clone_repo method."""
|
|
|
|
def test_clone_repo_calls_git_command(self, db_session, test_env_vars):
|
|
"""Test that _clone_repo calls git clone with correct arguments."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.return_value = Mock(returncode=0)
|
|
|
|
service._clone_repo(
|
|
"git@gitea.example.com:owner/repo.git",
|
|
"/tmp/repo",
|
|
VALID_SSH_KEY
|
|
)
|
|
|
|
# Verify subprocess.run was called
|
|
assert mock_run.called
|
|
call_args = mock_run.call_args
|
|
assert 'git' in call_args[0][0]
|
|
|
|
def test_clone_repo_creates_ssh_wrapper(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that _clone_repo creates SSH key wrapper."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.return_value = Mock(returncode=0)
|
|
with patch('tempfile.NamedTemporaryFile') as mock_temp:
|
|
mock_temp.return_value.__enter__.return_value.name = "/tmp/test_key"
|
|
|
|
service._clone_repo(
|
|
"git@gitea.example.com:owner/repo.git",
|
|
str(tmp_path / "repo"),
|
|
VALID_SSH_KEY
|
|
)
|
|
|
|
# Verify temp file was created
|
|
mock_temp.assert_called()
|
|
|
|
def test_clone_repo_handles_failure(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that _clone_repo handles git clone failures."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
|
|
|
|
with pytest.raises(Exception):
|
|
service._clone_repo(
|
|
"git@gitea.example.com:owner/repo.git",
|
|
str(tmp_path / "repo"),
|
|
VALID_SSH_KEY
|
|
)
|
|
|
|
|
|
class TestFetchRepo:
|
|
"""Tests for _fetch_repo method."""
|
|
|
|
def test_fetch_repo_calls_git_fetch(self, db_session, test_env_vars):
|
|
"""Test that _fetch_repo calls git fetch --all."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.return_value = Mock(returncode=0)
|
|
|
|
service._fetch_repo("/tmp/repo", VALID_SSH_KEY)
|
|
|
|
# Verify subprocess.run was called
|
|
assert mock_run.called
|
|
call_args = mock_run.call_args
|
|
assert 'git' in call_args[0][0]
|
|
|
|
def test_fetch_repo_handles_failure(self, db_session, test_env_vars):
|
|
"""Test that _fetch_repo handles git fetch failures."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
|
|
|
|
with pytest.raises(Exception):
|
|
service._fetch_repo("/tmp/repo", VALID_SSH_KEY)
|
|
|
|
|
|
class TestCountCommits:
|
|
"""Tests for _count_commits method."""
|
|
|
|
def test_count_commits_returns_zero_for_nonexistent_repo(self, db_session, test_env_vars):
|
|
"""Test that _count_commits returns 0 for non-existent repo."""
|
|
service = SyncService(db_session)
|
|
|
|
count = service._count_commits("/nonexistent/repo")
|
|
|
|
assert count == 0
|
|
|
|
def test_count_commits_parses_git_output(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that _count_commits parses git rev-list output."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
# Simulate git output with commit count
|
|
result = Mock()
|
|
result.stdout.decode.return_value = "a1b2c3d\ne4f5g6h\n"
|
|
result.returncode = 0
|
|
mock_run.return_value = result
|
|
|
|
count = service._count_commits(str(tmp_path))
|
|
|
|
assert count == 2
|
|
|
|
def test_count_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that _count_commits handles git command failure."""
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
|
|
|
|
count = service._count_commits(str(tmp_path))
|
|
|
|
assert count == 0
|
|
|
|
|
|
class TestGetRepoCommits:
|
|
"""Tests for get_repo_commits method."""
|
|
|
|
def test_get_repo_commits_returns_empty_list_for_nonexistent_repo(self, db_session, test_env_vars):
|
|
"""Test that get_repo_commits returns empty list for non-existent repo."""
|
|
repo = create_test_repo(db_session, local_path="/nonexistent/repo")
|
|
service = SyncService(db_session)
|
|
|
|
commits = service.get_repo_commits(repo, limit=10)
|
|
|
|
assert commits == []
|
|
|
|
def test_get_repo_commits_parses_git_log_output(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that get_repo_commits parses git log output."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path))
|
|
service = SyncService(db_session)
|
|
|
|
git_log_output = """a1b2c3d4|First commit|author1|author1@example.com|1000000
|
|
e4f5g6h7|Second commit|author2|author2@example.com|2000000"""
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
result = Mock()
|
|
result.stdout.decode.return_value = git_log_output
|
|
result.returncode = 0
|
|
mock_run.return_value = result
|
|
|
|
commits = service.get_repo_commits(repo, limit=10)
|
|
|
|
assert len(commits) == 2
|
|
assert commits[0]['hash'] == 'a1b2c3d4'
|
|
assert commits[0]['message'] == 'First commit'
|
|
assert commits[1]['hash'] == 'e4f5g6h7'
|
|
assert commits[1]['message'] == 'Second commit'
|
|
|
|
def test_get_repo_commits_respects_limit(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that get_repo_commits respects the limit parameter."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path))
|
|
service = SyncService(db_session)
|
|
|
|
git_log_output = """a1b2c3d4|Commit 1|author1|author1@example.com|1000000
|
|
e4f5g6h7|Commit 2|author2|author2@example.com|2000000
|
|
i7j8k9l0|Commit 3|author3|author3@example.com|3000000"""
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
result = Mock()
|
|
result.stdout.decode.return_value = git_log_output
|
|
result.returncode = 0
|
|
mock_run.return_value = result
|
|
|
|
commits = service.get_repo_commits(repo, limit=2)
|
|
|
|
assert len(commits) == 2
|
|
|
|
def test_get_repo_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path):
|
|
"""Test that get_repo_commits handles git command failure."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path))
|
|
service = SyncService(db_session)
|
|
|
|
with patch('subprocess.run') as mock_run:
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
|
|
|
|
commits = service.get_repo_commits(repo, limit=10)
|
|
|
|
assert commits == []
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for Sync service."""
|
|
|
|
def test_sync_and_get_commits_workflow(self, db_session, test_env_vars, tmp_path):
|
|
"""Test complete workflow of syncing and getting commits."""
|
|
repo = create_test_repo(db_session, local_path=str(tmp_path / "repo"))
|
|
service = SyncService(db_session)
|
|
|
|
with patch.object(service, '_clone_repo'):
|
|
service.sync_repo(repo, VALID_SSH_KEY)
|
|
|
|
# Simulate commits exist
|
|
git_log_output = "a1b2c3d4|Test commit|author|author@example.com|1000000"
|
|
with patch('subprocess.run') as mock_run:
|
|
result = Mock()
|
|
result.stdout.decode.return_value = git_log_output
|
|
result.returncode = 0
|
|
mock_run.return_value = result
|
|
|
|
commits = service.get_repo_commits(repo, limit=10)
|
|
|
|
assert len(commits) == 1
|
|
assert repo.status == "syncing"
|