feat: complete Git Repo Manager MVP implementation

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>
This commit is contained in:
panw
2026-03-30 16:30:13 +08:00
parent 960056c88c
commit 44921c5646
46 changed files with 6533 additions and 2 deletions

View File

@@ -1,12 +1,13 @@
import sys
import pytest
from pathlib import Path
from typing import Generator
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, Session
# NOTE: This import will fail until models are created in Task 2.1
# This is expected behavior - the models module doesn't exist yet
@@ -58,3 +59,71 @@ def test_env_vars(db_path, test_encrypt_key, monkeypatch):
"GM_ENCRYPT_KEY": test_encrypt_key,
"GM_API_TOKEN": "test-token",
}
@pytest.fixture(scope="function")
def client(db_session: Session, test_env_vars: dict, monkeypatch):
"""
FastAPI test client fixture.
Provides a test client for the FastAPI application with:
- In-memory database session
- Test environment variables
- Disabled lifespan (for faster tests)
Args:
db_session: Database session fixture
test_env_vars: Test environment variables fixture
monkeypatch: Pytest monkeypatch fixture
Yields:
FastAPI test client
Example:
def test_create_ssh_key(client):
response = client.post(
"/api/ssh-keys",
json={"name": "test-key", "private_key": "..."},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 201
"""
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, patch
# Mock the lifespan context manager
async def mock_lifespan(app): # noqa: ARG001 - Unused app parameter
# Database is already initialized by db_session fixture
yield
# Import after setting env vars
import app.database
import app.config
# Initialize database with test session
app.database._engine = db_session.bind
app.database._session_factory = sessionmaker(bind=db_session.bind, autocommit=False, autoflush=False)
# Create app with mocked lifespan
from app.main import create_app
test_app = create_app(lifespan_handler=mock_lifespan)
# Override get_db_session dependency to use test session
from app.api import deps
def override_get_db_session():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[deps.get_db_session] = override_get_db_session
with TestClient(test_app) as test_client:
yield test_client
# Clean up
test_app.dependency_overrides = {}
app.database._engine = None
app.database._session_factory = None
app.config._settings = None

View File

@@ -0,0 +1,5 @@
"""
Tests for API routes.
This package contains tests for all FastAPI route handlers.
"""

View File

@@ -0,0 +1,495 @@
"""
Tests for Servers API routes.
Tests the following endpoints:
- POST /api/servers
- GET /api/servers
- GET /api/servers/{id}
- PUT /api/servers/{id}
- DELETE /api/servers/{id}
"""
import pytest
from fastapi.testclient import TestClient
# Valid SSH private key for testing servers
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR
AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA
AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH
AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg=="""
class TestCreateServer:
"""Tests for POST /api/servers endpoint."""
def test_create_server_success(self, client: TestClient):
"""Test creating a new server successfully."""
# First create an SSH key
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
# Create server
response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-api-token-123",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 201
data = response.json()
assert data["code"] == 0
assert data["message"] == "Server created successfully"
assert data["data"]["name"] == "test-server"
assert data["data"]["url"] == "https://gitea.example.com"
assert data["data"]["ssh_key_id"] == ssh_key_id
assert data["data"]["id"] > 0
assert data["data"]["status"] == "untested"
assert data["data"]["sync_enabled"] is False
assert data["data"]["created_at"] > 0
def test_create_server_with_sync_enabled(self, client: TestClient):
"""Test creating a server with sync enabled."""
# Create SSH key
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-2", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
# Create server with sync enabled
response = client.post(
"/api/servers",
json={
"name": "sync-server",
"url": "https://gitea.example.com",
"api_token": "test-api-token",
"ssh_key_id": ssh_key_id,
"sync_enabled": True,
"schedule_cron": "0 */6 * * *"
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 201
data = response.json()
assert data["data"]["sync_enabled"] is True
assert data["data"]["schedule_cron"] == "0 */6 * * *"
def test_create_server_duplicate_name(self, client: TestClient):
"""Test creating a server with duplicate name fails."""
# Create SSH key
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-3", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
# Create first server
client.post(
"/api/servers",
json={
"name": "duplicate-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
# Try to create duplicate
response = client.post(
"/api/servers",
json={
"name": "duplicate-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"]
def test_create_server_invalid_ssh_key_id(self, client: TestClient):
"""Test creating a server with non-existent SSH key fails."""
response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": 99999 # Non-existent
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
assert "not found" in response.json()["detail"]
def test_create_server_no_auth(self, client: TestClient):
"""Test creating a server without authentication fails."""
response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": 1
}
)
assert response.status_code == 401
class TestListServers:
"""Tests for GET /api/servers endpoint."""
def test_list_servers_empty(self, client: TestClient):
"""Test listing servers when none exist."""
response = client.get(
"/api/servers",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"] == []
assert "0 server" in data["message"]
def test_list_servers_with_items(self, client: TestClient):
"""Test listing servers when some exist."""
# Create SSH key
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-list", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
# Create some servers
client.post(
"/api/servers",
json={
"name": "server-1",
"url": "https://gitea1.example.com",
"api_token": "token1",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
client.post(
"/api/servers",
json={
"name": "server-2",
"url": "https://gitea2.example.com",
"api_token": "token2",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
response = client.get(
"/api/servers",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert len(data["data"]) == 2
assert "api_token" not in data["data"][0] # Should not expose token
def test_list_servers_no_auth(self, client: TestClient):
"""Test listing servers without authentication fails."""
response = client.get("/api/servers")
assert response.status_code == 401
class TestGetServer:
"""Tests for GET /api/servers/{id} endpoint."""
def test_get_server_success(self, client: TestClient):
"""Test getting a specific server successfully."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-get", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "get-test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Get the server
response = client.get(
f"/api/servers/{server_id}",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["id"] == server_id
assert data["data"]["name"] == "get-test-server"
assert "api_token" not in data["data"]
def test_get_server_not_found(self, client: TestClient):
"""Test getting a non-existent server fails."""
response = client.get(
"/api/servers/99999",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 404
def test_get_server_no_auth(self, client: TestClient):
"""Test getting a server without authentication fails."""
response = client.get("/api/servers/1")
assert response.status_code == 401
class TestUpdateServer:
"""Tests for PUT /api/servers/{id} endpoint."""
def test_update_server_name(self, client: TestClient):
"""Test updating a server's name."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-update", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "old-name",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Update name
response = client.put(
f"/api/servers/{server_id}",
json={"name": "new-name"},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["name"] == "new-name"
def test_update_server_multiple_fields(self, client: TestClient):
"""Test updating multiple server fields."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-multi", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Update multiple fields
response = client.put(
f"/api/servers/{server_id}",
json={
"sync_enabled": True,
"schedule_cron": "0 */6 * * *",
"status": "testing"
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["data"]["sync_enabled"] is True
assert data["data"]["schedule_cron"] == "0 */6 * * *"
assert data["data"]["status"] == "testing"
def test_update_server_api_token(self, client: TestClient):
"""Test updating a server's API token."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-token", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "old-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Update API token
response = client.put(
f"/api/servers/{server_id}",
json={"api_token": "new-token"},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
def test_update_server_not_found(self, client: TestClient):
"""Test updating a non-existent server fails."""
response = client.put(
"/api/servers/99999",
json={"name": "new-name"},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 404
def test_update_server_no_fields(self, client: TestClient):
"""Test updating a server with no fields fails."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-nofield", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Update with empty body
response = client.put(
f"/api/servers/{server_id}",
json={},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
def test_update_server_no_auth(self, client: TestClient):
"""Test updating a server without authentication fails."""
response = client.put(
"/api/servers/1",
json={"name": "new-name"}
)
assert response.status_code == 401
class TestDeleteServer:
"""Tests for DELETE /api/servers/{id} endpoint."""
def test_delete_server_success(self, client: TestClient):
"""Test deleting a server successfully."""
# Create SSH key and server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "test-key-del", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
server_response = client.post(
"/api/servers",
json={
"name": "delete-test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
server_id = server_response.json()["data"]["id"]
# Delete the server
response = client.delete(
f"/api/servers/{server_id}",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "Server deleted successfully"
# Verify it's deleted
get_response = client.get(
f"/api/servers/{server_id}",
headers={"Authorization": "Bearer test-token"}
)
assert get_response.status_code == 404
def test_delete_server_not_found(self, client: TestClient):
"""Test deleting a non-existent server fails."""
response = client.delete(
"/api/servers/99999",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 404
def test_delete_server_no_auth(self, client: TestClient):
"""Test deleting a server without authentication fails."""
response = client.delete("/api/servers/1")
assert response.status_code == 401

View File

@@ -0,0 +1,299 @@
"""
Tests for SSH Keys API routes.
Tests the following endpoints:
- POST /api/ssh-keys
- GET /api/ssh-keys
- GET /api/ssh-keys/{id}
- DELETE /api/ssh-keys/{id}
"""
import pytest
from fastapi.testclient import TestClient
# Valid SSH private key for testing
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR
AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA
AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH
AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg=="""
# Invalid SSH key for testing
INVALID_SSH_KEY = "not-a-valid-ssh-key"
class TestCreateSshKey:
"""Tests for POST /api/ssh-keys endpoint."""
def test_create_ssh_key_success(self, client: TestClient):
"""Test creating a new SSH key successfully."""
response = client.post(
"/api/ssh-keys",
json={
"name": "test-key",
"private_key": VALID_SSH_KEY
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 201
data = response.json()
assert data["code"] == 0
assert data["message"] == "SSH key created successfully"
assert data["data"]["name"] == "test-key"
assert data["data"]["id"] > 0
assert data["data"]["fingerprint"] is not None
assert data["data"]["created_at"] > 0
def test_create_ssh_key_duplicate_name(self, client: TestClient):
"""Test creating a SSH key with duplicate name fails."""
# Create first key
client.post(
"/api/ssh-keys",
json={
"name": "duplicate-key",
"private_key": VALID_SSH_KEY
},
headers={"Authorization": "Bearer test-token"}
)
# Try to create duplicate
response = client.post(
"/api/ssh-keys",
json={
"name": "duplicate-key",
"private_key": VALID_SSH_KEY
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
data = response.json()
assert "already exists" in data["detail"]
def test_create_ssh_key_invalid_key_format(self, client: TestClient):
"""Test creating a SSH key with invalid format fails."""
response = client.post(
"/api/ssh-keys",
json={
"name": "invalid-key",
"private_key": INVALID_SSH_KEY
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
data = response.json()
assert "Invalid SSH private key format" in data["detail"]
def test_create_ssh_key_empty_name(self, client: TestClient):
"""Test creating a SSH key with empty name fails."""
response = client.post(
"/api/ssh-keys",
json={
"name": "",
"private_key": VALID_SSH_KEY
},
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 422 # Validation error
def test_create_ssh_key_no_auth(self, client: TestClient):
"""Test creating a SSH key without authentication fails."""
response = client.post(
"/api/ssh-keys",
json={
"name": "test-key",
"private_key": VALID_SSH_KEY
}
)
assert response.status_code == 401
def test_create_ssh_key_invalid_token(self, client: TestClient):
"""Test creating a SSH key with invalid token fails."""
response = client.post(
"/api/ssh-keys",
json={
"name": "test-key",
"private_key": VALID_SSH_KEY
},
headers={"Authorization": "Bearer invalid-token"}
)
assert response.status_code == 401
class TestListSshKeys:
"""Tests for GET /api/ssh-keys endpoint."""
def test_list_ssh_keys_empty(self, client: TestClient):
"""Test listing SSH keys when none exist."""
response = client.get(
"/api/ssh-keys",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"] == []
assert "0 SSH key" in data["message"]
def test_list_ssh_keys_with_items(self, client: TestClient):
"""Test listing SSH keys when some exist."""
# Create some keys
client.post(
"/api/ssh-keys",
json={"name": "key-1", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
client.post(
"/api/ssh-keys",
json={"name": "key-2", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
response = client.get(
"/api/ssh-keys",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert len(data["data"]) == 2
assert data["data"][0]["name"] in ["key-1", "key-2"]
assert "private_key" not in data["data"][0] # Should not expose private key
def test_list_ssh_keys_no_auth(self, client: TestClient):
"""Test listing SSH keys without authentication fails."""
response = client.get("/api/ssh-keys")
assert response.status_code == 401
class TestGetSshKey:
"""Tests for GET /api/ssh-keys/{id} endpoint."""
def test_get_ssh_key_success(self, client: TestClient):
"""Test getting a specific SSH key successfully."""
# Create a key first
create_response = client.post(
"/api/ssh-keys",
json={"name": "get-test-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
key_id = create_response.json()["data"]["id"]
# Get the key
response = client.get(
f"/api/ssh-keys/{key_id}",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["id"] == key_id
assert data["data"]["name"] == "get-test-key"
assert "private_key" not in data["data"]
def test_get_ssh_key_not_found(self, client: TestClient):
"""Test getting a non-existent SSH key fails."""
response = client.get(
"/api/ssh-keys/99999",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 404
assert "not found" in response.json()["detail"]
def test_get_ssh_key_no_auth(self, client: TestClient):
"""Test getting an SSH key without authentication fails."""
response = client.get("/api/ssh-keys/1")
assert response.status_code == 401
class TestDeleteSshKey:
"""Tests for DELETE /api/ssh-keys/{id} endpoint."""
def test_delete_ssh_key_success(self, client: TestClient):
"""Test deleting an SSH key successfully."""
# Create a key first
create_response = client.post(
"/api/ssh-keys",
json={"name": "delete-test-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
key_id = create_response.json()["data"]["id"]
# Delete the key
response = client.delete(
f"/api/ssh-keys/{key_id}",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "SSH key deleted successfully"
# Verify it's deleted
get_response = client.get(
f"/api/ssh-keys/{key_id}",
headers={"Authorization": "Bearer test-token"}
)
assert get_response.status_code == 404
def test_delete_ssh_key_not_found(self, client: TestClient):
"""Test deleting a non-existent SSH key fails."""
response = client.delete(
"/api/ssh-keys/99999",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 404
def test_delete_ssh_key_in_use(self, client: TestClient):
"""Test deleting an SSH key that is in use by a server fails."""
# Create an SSH key
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "in-use-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
# Create a server using this key
client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-api-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
# Try to delete the SSH key
response = client.delete(
f"/api/ssh-keys/{ssh_key_id}",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 400
assert "in use" in response.json()["detail"].lower()
def test_delete_ssh_key_no_auth(self, client: TestClient):
"""Test deleting an SSH key without authentication fails."""
response = client.delete("/api/ssh-keys/1")
assert response.status_code == 401

View File

@@ -0,0 +1,151 @@
"""
Tests for Status API routes.
Tests the following endpoints:
- GET /api/status
- GET /api/status/health
"""
import pytest
from fastapi.testclient import TestClient
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR
AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA
AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH
AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg=="""
class TestGetStatus:
"""Tests for GET /api/status endpoint."""
def test_get_status_unauthenticated(self, client: TestClient):
"""Test getting status without authentication."""
response = client.get("/api/status")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "System status retrieved successfully"
assert data["data"]["status"] == "healthy"
assert data["data"]["version"] == "1.0.0"
assert data["data"]["authenticated"] is False
assert "database" in data["data"]
assert data["data"]["database"]["status"] == "connected"
# Storage paths should not be included for unauthenticated
assert "storage" not in data["data"]
def test_get_status_authenticated(self, client: TestClient):
"""Test getting status with authentication."""
response = client.get(
"/api/status",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["authenticated"] is True
assert "storage" in data["data"]
assert "data_dir" in data["data"]["storage"]
assert "repos_dir" in data["data"]["storage"]
assert "ssh_keys_dir" in data["data"]["storage"]
assert "db_path" in data["data"]["storage"]
def test_get_status_with_data(self, client: TestClient):
"""Test getting status when data exists."""
# Create an SSH key
client.post(
"/api/ssh-keys",
json={"name": "test-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
# Get status
response = client.get(
"/api/status",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["data"]["database"]["ssh_keys_count"] == 1
assert data["data"]["database"]["servers_count"] == 0
assert data["data"]["database"]["repos_count"] == 0
def test_get_status_database_counts(self, client: TestClient):
"""Test database counts in status are accurate."""
# Create multiple items
client.post(
"/api/ssh-keys",
json={"name": "key-1", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
client.post(
"/api/ssh-keys",
json={"name": "key-2", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
# Create a server
ssh_response = client.post(
"/api/ssh-keys",
json={"name": "server-key", "private_key": VALID_SSH_KEY},
headers={"Authorization": "Bearer test-token"}
)
ssh_key_id = ssh_response.json()["data"]["id"]
client.post(
"/api/servers",
json={
"name": "test-server",
"url": "https://gitea.example.com",
"api_token": "test-token",
"ssh_key_id": ssh_key_id
},
headers={"Authorization": "Bearer test-token"}
)
# Get status
response = client.get(
"/api/status",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
data = response.json()
assert data["data"]["database"]["ssh_keys_count"] == 3
assert data["data"]["database"]["servers_count"] == 1
class TestHealthCheck:
"""Tests for GET /api/status/health endpoint."""
def test_health_check(self, client: TestClient):
"""Test the health check endpoint."""
response = client.get("/api/status/health")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["status"] == "ok"
assert data["message"] == "Service is healthy"
def test_health_check_no_auth_required(self, client: TestClient):
"""Test health check works without authentication."""
response = client.get("/api/status/health")
assert response.status_code == 200
def test_health_check_with_auth(self, client: TestClient):
"""Test health check works with authentication."""
response = client.get(
"/api/status/health",
headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200

View File

@@ -0,0 +1,411 @@
"""
Tests for Repo Service.
"""
import base64
import pytest
import time
from pathlib import Path
from app.models.repo import Repo
from app.models.server import Server
from app.services.repo_service import RepoService
from app.services.server_service import ServerService
from app.services.ssh_key_service import SshKeyService
# 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_with_repo(db_session, server_name="test-server"):
"""Helper to create a test server."""
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=server_name,
url="https://gitea.example.com",
api_token="test-api-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
class TestCreateRepo:
"""Tests for create_repo method."""
def test_create_repo_success(self, db_session, test_env_vars):
"""Test successful repository creation."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="test-repo",
full_name="owner/test-repo",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path="/tmp/test-repo"
)
assert repo.id is not None
assert repo.server_id == server.id
assert repo.name == "test-repo"
assert repo.full_name == "owner/test-repo"
assert repo.clone_url == "git@gitea.example.com:owner/test-repo.git"
assert repo.local_path == "/tmp/test-repo"
assert repo.status == "pending"
assert repo.last_sync_at is None
assert repo.created_at is not None
def test_create_repo_with_invalid_server_id(self, db_session, test_env_vars):
"""Test that invalid server_id is rejected."""
service = RepoService(db_session)
with pytest.raises(ValueError, match="Server not found"):
service.create_repo(
server_id=99999,
name="test-repo",
full_name="owner/test-repo",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path="/tmp/test-repo"
)
def test_create_repo_generates_local_path(self, db_session, test_env_vars):
"""Test that local_path can be generated correctly."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Test with None local_path to let it generate
repo = service.create_repo(
server_id=server.id,
name="auto-path-repo",
full_name="owner/auto-path-repo",
clone_url="git@gitea.example.com:owner/auto-path-repo.git",
local_path=None
)
# Should generate path based on server and repo name
assert repo.local_path is not None
assert "auto-path-repo" in repo.local_path
def test_create_repo_duplicate_name_same_server(self, db_session, test_env_vars):
"""Test that duplicate repo names on same server are allowed (different from servers)."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Create first repo
service.create_repo(
server_id=server.id,
name="duplicate-repo",
full_name="owner1/duplicate-repo",
clone_url="git@gitea.example.com:owner1/duplicate-repo.git",
local_path="/tmp/duplicate-repo-1"
)
# Create second repo with same name but different full_name
repo2 = service.create_repo(
server_id=server.id,
name="duplicate-repo",
full_name="owner2/duplicate-repo",
clone_url="git@gitea.example.com:owner2/duplicate-repo.git",
local_path="/tmp/duplicate-repo-2"
)
assert repo2 is not None
assert repo2.full_name == "owner2/duplicate-repo"
def test_create_repo_default_status(self, db_session, test_env_vars):
"""Test that new repos have default 'pending' status."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-test-repo",
full_name="owner/status-test-repo",
clone_url="git@gitea.example.com:owner/status-test-repo.git",
local_path="/tmp/status-test-repo"
)
assert repo.status == "pending"
class TestListRepos:
"""Tests for list_repos method."""
def test_list_repos_empty(self, db_session, test_env_vars):
"""Test listing repos when none exist."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repos = service.list_repos(server.id)
assert repos == []
def test_list_repos_multiple(self, db_session, test_env_vars):
"""Test listing multiple repos for a server."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
service.create_repo(
server_id=server.id,
name="repo-1",
full_name="owner/repo-1",
clone_url="git@gitea.example.com:owner/repo-1.git",
local_path="/tmp/repo-1"
)
service.create_repo(
server_id=server.id,
name="repo-2",
full_name="owner/repo-2",
clone_url="git@gitea.example.com:owner/repo-2.git",
local_path="/tmp/repo-2"
)
repos = service.list_repos(server.id)
assert len(repos) == 2
assert any(r.name == "repo-1" for r in repos)
assert any(r.name == "repo-2" for r in repos)
def test_list_repos_filters_by_server(self, db_session, test_env_vars):
"""Test that list_repos only returns repos for specified server."""
server1 = create_test_server_with_repo(db_session, "server-1")
server2 = create_test_server_with_repo(db_session, "server-2")
service = RepoService(db_session)
# Create repo for server1
service.create_repo(
server_id=server1.id,
name="server1-repo",
full_name="owner/server1-repo",
clone_url="git@gitea.example.com:owner/server1-repo.git",
local_path="/tmp/server1-repo"
)
# Create repo for server2
service.create_repo(
server_id=server2.id,
name="server2-repo",
full_name="owner/server2-repo",
clone_url="git@gitea.example.com:owner/server2-repo.git",
local_path="/tmp/server2-repo"
)
# List repos for server1 should only return server1's repo
server1_repos = service.list_repos(server1.id)
assert len(server1_repos) == 1
assert server1_repos[0].name == "server1-repo"
# List repos for server2 should only return server2's repo
server2_repos = service.list_repos(server2.id)
assert len(server2_repos) == 1
assert server2_repos[0].name == "server2-repo"
def test_list_repos_ordered_by_creation(self, db_session, test_env_vars):
"""Test that repos are listed in creation order."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo1 = service.create_repo(
server_id=server.id,
name="first-repo",
full_name="owner/first-repo",
clone_url="git@gitea.example.com:owner/first-repo.git",
local_path="/tmp/first-repo"
)
time.sleep(0.1) # Small delay to ensure timestamp difference
repo2 = service.create_repo(
server_id=server.id,
name="second-repo",
full_name="owner/second-repo",
clone_url="git@gitea.example.com:owner/second-repo.git",
local_path="/tmp/second-repo"
)
repos = service.list_repos(server.id)
assert repos[0].id == repo1.id
assert repos[1].id == repo2.id
class TestGetRepo:
"""Tests for get_repo method."""
def test_get_repo_by_id(self, db_session, test_env_vars):
"""Test getting a repo by ID."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
created_repo = service.create_repo(
server_id=server.id,
name="get-test-repo",
full_name="owner/get-test-repo",
clone_url="git@gitea.example.com:owner/get-test-repo.git",
local_path="/tmp/get-test-repo"
)
retrieved_repo = service.get_repo(created_repo.id)
assert retrieved_repo is not None
assert retrieved_repo.id == created_repo.id
assert retrieved_repo.name == "get-test-repo"
def test_get_repo_not_found(self, db_session, test_env_vars):
"""Test getting a non-existent repo."""
service = RepoService(db_session)
repo = service.get_repo(99999)
assert repo is None
class TestUpdateRepoStatus:
"""Tests for update_repo_status method."""
def test_update_repo_status_to_syncing(self, db_session, test_env_vars):
"""Test updating repo status to syncing."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "syncing")
assert updated_repo.status == "syncing"
assert updated_repo.id == repo.id
def test_update_repo_status_to_success(self, db_session, test_env_vars):
"""Test updating repo status to success."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "success")
assert updated_repo.status == "success"
def test_update_repo_status_to_failed(self, db_session, test_env_vars):
"""Test updating repo status to failed."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "failed")
assert updated_repo.status == "failed"
def test_update_repo_status_not_found(self, db_session, test_env_vars):
"""Test updating status for non-existent repo."""
service = RepoService(db_session)
with pytest.raises(ValueError, match="Repo not found"):
service.update_repo_status(99999, "syncing")
class TestDeleteRepo:
"""Tests for delete_repo method."""
def test_delete_repo_success(self, db_session, test_env_vars):
"""Test successful repo deletion."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="delete-test-repo",
full_name="owner/delete-test-repo",
clone_url="git@gitea.example.com:owner/delete-test-repo.git",
local_path="/tmp/delete-test-repo"
)
result = service.delete_repo(repo.id)
assert result is True
# Verify the repo is deleted
retrieved_repo = service.get_repo(repo.id)
assert retrieved_repo is None
def test_delete_repo_not_found(self, db_session, test_env_vars):
"""Test deleting a non-existent repo."""
service = RepoService(db_session)
result = service.delete_repo(99999)
assert result is False
class TestIntegration:
"""Integration tests for Repo service."""
def test_repo_lifecycle(self, db_session, test_env_vars):
"""Test complete lifecycle of a repo."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Create repo
repo = service.create_repo(
server_id=server.id,
name="lifecycle-repo",
full_name="owner/lifecycle-repo",
clone_url="git@gitea.example.com:owner/lifecycle-repo.git",
local_path="/tmp/lifecycle-repo"
)
assert repo.status == "pending"
# Update status to syncing
repo = service.update_repo_status(repo.id, "syncing")
assert repo.status == "syncing"
# Update status to success
repo = service.update_repo_status(repo.id, "success")
assert repo.status == "success"
# Verify we can retrieve it
retrieved = service.get_repo(repo.id)
assert retrieved is not None
assert retrieved.status == "success"
# List repos for server
repos = service.list_repos(server.id)
assert len(repos) == 1
assert repos[0].id == repo.id
# Delete repo
result = service.delete_repo(repo.id)
assert result is True
# Verify it's gone
repos = service.list_repos(server.id)
assert len(repos) == 0

View File

@@ -0,0 +1,323 @@
"""
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"