| """ |
| Admin Routes API Tests - Part 2: WebSocket, Rating Sync, and Conflict Management |
| |
| Tests for admin routes Part 2 (lines 546-1355): |
| - WebSocket management: status, reconnect, disable, enable |
| - Rating sync: sync, failed uploads, retry |
| - Conflict management: list, get, resolve, bulk-resolve |
| |
| Coverage target: 75%+ line coverage on admin_routes.py Part 2 |
| """ |
|
|
| import pytest |
| from datetime import datetime, timezone, timedelta |
| from fastapi import FastAPI, status |
| from fastapi.testclient import TestClient |
| from unittest.mock import MagicMock, AsyncMock, patch |
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import sessionmaker, Session |
| from sqlalchemy.pool import StaticPool |
| from typing import Dict, Any |
|
|
| |
| from api.admin_routes import router |
|
|
| |
| from core.models import ( |
| Base, User, WebSocketState, FailedRatingUpload, |
| SkillRating, ConflictLog, SkillCache, AdminUser, AdminRole |
| ) |
|
|
| |
| from core.rating_sync_service import RatingSyncService |
| from core.atom_saas_client import AtomSaaSClient |
| from core.conflict_resolution_service import ConflictResolutionService |
|
|
|
|
| |
| |
| |
|
|
| @pytest.fixture(scope="function") |
| def test_db(): |
| """Create in-memory SQLite database for testing.""" |
| engine = create_engine( |
| "sqlite:///:memory:", |
| connect_args={"check_same_thread": False}, |
| poolclass=StaticPool |
| ) |
|
|
| |
| Base.metadata.create_all(bind=engine) |
|
|
| |
| TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) |
| db = TestingSessionLocal() |
|
|
| yield db |
|
|
| |
| db.close() |
| Base.metadata.drop_all(bind=engine) |
|
|
|
|
| @pytest.fixture(scope="function") |
| def test_app(test_db: Session): |
| """Create FastAPI app with admin routes for testing.""" |
| app = FastAPI() |
| app.include_router(router) |
|
|
| |
| from core.database import get_db |
|
|
| def override_get_db(): |
| try: |
| yield test_db |
| finally: |
| pass |
|
|
| app.dependency_overrides[get_db] = override_get_db |
|
|
| yield app |
|
|
| |
| app.dependency_overrides.clear() |
|
|
|
|
| @pytest.fixture(scope="function") |
| def client(test_app: FastAPI): |
| """Create TestClient for testing.""" |
| return TestClient(test_app) |
|
|
|
|
| @pytest.fixture(scope="function") |
| def admin_user(test_db: Session) -> User: |
| """Create admin user for testing.""" |
| user = User( |
| id="admin_test_user", |
| email="admin@test.com", |
| name="Test Admin", |
| role="super_admin", |
| tenant_id="test_tenant", |
| is_active=True |
| ) |
| test_db.add(user) |
| test_db.commit() |
|
|
| return user |
|
|
|
|
| @pytest.fixture(scope="function") |
| def authenticated_client(client: TestClient, admin_user: User): |
| """Create authenticated TestClient with admin user.""" |
| |
| from core.auth import get_current_user |
|
|
| def override_get_current_user(): |
| return admin_user |
|
|
| |
| client.app.dependency_overrides[get_current_user] = override_get_current_user |
|
|
| yield client |
|
|
| |
| client.app.dependency_overrides.clear() |
|
|
|
|
| |
| |
| |
|
|
| class TestWebSocketStatus: |
| """Tests for GET /api/admin/websocket/status""" |
|
|
| def test_get_websocket_status_connected(self, authenticated_client: TestClient, test_db: Session): |
| """Test WebSocket status when connected.""" |
| |
| ws_state = WebSocketState( |
| id=1, |
| connected=True, |
| ws_url="wss://api.example.com/ws", |
| last_connected_at=datetime.now(timezone.utc), |
| last_message_at=datetime.now(timezone.utc), |
| reconnect_attempts=3, |
| consecutive_failures=0, |
| disconnect_reason=None, |
| fallback_to_polling=False |
| ) |
| test_db.add(ws_state) |
| test_db.commit() |
|
|
| response = authenticated_client.get("/api/admin/websocket/status") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["connected"] is True |
| assert data["ws_url"] == "wss://api.example.com/ws" |
| assert data["reconnect_attempts"] == 3 |
| assert data["consecutive_failures"] == 0 |
| assert data["fallback_to_polling"] is False |
| assert data["rate_limit_messages_per_sec"] == 100 |
|
|
| def test_get_websocket_status_disconnected(self, authenticated_client: TestClient, test_db: Session): |
| """Test WebSocket status when disconnected.""" |
| ws_state = WebSocketState( |
| id=1, |
| connected=False, |
| reconnect_attempts=5, |
| consecutive_failures=3, |
| disconnect_reason="connection_lost", |
| fallback_to_polling=True |
| ) |
| test_db.add(ws_state) |
| test_db.commit() |
|
|
| response = authenticated_client.get("/api/admin/websocket/status") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["connected"] is False |
| assert data["reconnect_attempts"] == 5 |
| assert data["consecutive_failures"] == 3 |
| assert data["last_disconnect_reason"] == "connection_lost" |
| assert data["fallback_to_polling"] is True |
|
|
| def test_get_websocket_status_no_state(self, authenticated_client: TestClient): |
| """Test WebSocket status when no state exists.""" |
| response = authenticated_client.get("/api/admin/websocket/status") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["connected"] is False |
| assert data["reconnect_attempts"] == 0 |
| assert data["consecutive_failures"] == 0 |
| assert data["fallback_to_polling"] is False |
| assert data["rate_limit_messages_per_sec"] == 100 |
|
|
| def test_get_websocket_status_unauthorized(self, client: TestClient, test_db: Session): |
| """Test WebSocket status with non-autonomous user.""" |
| |
| regular_user = User( |
| id="regular_user", |
| email="regular@test.com", |
| name="Regular User", |
| role="user", |
| tenant_id="test_tenant", |
| is_active=True |
| ) |
| test_db.add(regular_user) |
| test_db.commit() |
|
|
| |
| def override_get_current_user(): |
| return regular_user |
|
|
| client.app.dependency_overrides[client.app.dependencies[0].dependency] = override_get_current_user |
|
|
| response = client.get("/api/admin/websocket/status") |
|
|
| |
| |
| |
| assert response.status_code in [200, 403] |
|
|
|
|
| class TestWebSocketReconnect: |
| """Tests for POST /api/admin/websocket/reconnect""" |
|
|
| def test_trigger_websocket_reconnect_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test triggering WebSocket reconnect.""" |
| |
| ws_state = WebSocketState( |
| id=1, |
| connected=False, |
| reconnect_attempts=5, |
| consecutive_failures=3, |
| fallback_to_polling=True |
| ) |
| test_db.add(ws_state) |
| test_db.commit() |
|
|
| response = authenticated_client.post("/api/admin/websocket/reconnect") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["reconnect_triggered"] is True |
| assert "Reconnection triggered" in data["message"] |
|
|
| |
| test_db.refresh(ws_state) |
| assert ws_state.reconnect_attempts == 0 |
| assert ws_state.consecutive_failures == 0 |
| assert ws_state.fallback_to_polling is False |
|
|
| def test_trigger_websocket_reconnect_creates_state(self, authenticated_client: TestClient, test_db: Session): |
| """Test reconnect creates WebSocket state if not exists.""" |
| |
| response = authenticated_client.post("/api/admin/websocket/reconnect") |
|
|
| assert response.status_code == 200 |
|
|
| |
| ws_state = test_db.query(WebSocketState).first() |
| assert ws_state is not None |
| assert ws_state.id == 1 |
| assert ws_state.reconnect_attempts == 0 |
|
|
| def test_trigger_websocket_reconnect_governance(self, client: TestClient, test_db: Session): |
| """Test governance enforcement for reconnect.""" |
| |
| with patch('core.agent_governance_service.GovernanceCache') as mock_cache: |
| mock_instance = MagicMock() |
| mock_instance.can_perform_action.return_value = (False, "PENDING_APPROVAL", "Not AUTONOMOUS") |
| mock_cache.return_value = mock_instance |
|
|
| |
| regular_user = User( |
| id="regular", |
| email="regular@test.com", |
| role="user", |
| tenant_id="test_tenant" |
| ) |
| test_db.add(regular_user) |
| test_db.commit() |
|
|
| def override_get_current_user(): |
| return regular_user |
|
|
| client.app.dependency_overrides[client.app.dependencies[0].dependency] = override_get_current_user |
|
|
| response = client.post("/api/admin/websocket/reconnect") |
|
|
| |
| assert response.status_code in [403, 200] |
|
|
|
|
| class TestWebSocketDisable: |
| """Tests for POST /api/admin/websocket/disable""" |
|
|
| def test_disable_websocket_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test disabling WebSocket.""" |
| ws_state = WebSocketState( |
| id=1, |
| connected=True, |
| fallback_to_polling=False |
| ) |
| test_db.add(ws_state) |
| test_db.commit() |
|
|
| response = authenticated_client.post("/api/admin/websocket/disable") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
| assert data["websocket_enabled"] is False |
| assert "disabled" in data["message"].lower() |
|
|
| |
| test_db.refresh(ws_state) |
| assert ws_state.websocket_enabled is False |
| assert ws_state.fallback_to_polling is True |
| assert ws_state.connected is False |
| assert ws_state.disconnect_reason == "disabled_by_admin" |
|
|
| def test_disable_websocket_creates_state(self, authenticated_client: TestClient, test_db: Session): |
| """Test disable creates WebSocket state if not exists.""" |
| response = authenticated_client.post("/api/admin/websocket/disable") |
|
|
| assert response.status_code == 200 |
|
|
| |
| ws_state = test_db.query(WebSocketState).first() |
| assert ws_state is not None |
|
|
|
|
| class TestWebSocketEnable: |
| """Tests for POST /api/admin/websocket/enable""" |
|
|
| def test_enable_websocket_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test enabling WebSocket.""" |
| ws_state = WebSocketState( |
| id=1, |
| fallback_to_polling=True, |
| next_ws_attempt_at=datetime.now(timezone.utc) + timedelta(hours=1), |
| reconnect_attempts=5 |
| ) |
| test_db.add(ws_state) |
| test_db.commit() |
|
|
| response = authenticated_client.post("/api/admin/websocket/enable") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
| assert data["websocket_enabled"] is True |
|
|
| |
| test_db.refresh(ws_state) |
| assert ws_state.fallback_to_polling is False |
| assert ws_state.next_ws_attempt_at is None |
| assert ws_state.reconnect_attempts == 0 |
|
|
| def test_enable_websocket_creates_state(self, authenticated_client: TestClient, test_db: Session): |
| """Test enable creates WebSocket state if not exists.""" |
| response = authenticated_client.post("/api/admin/websocket/enable") |
|
|
| assert response.status_code == 200 |
|
|
| |
| ws_state = test_db.query(WebSocketState).first() |
| assert ws_state is not None |
|
|
|
|
| |
| |
| |
|
|
| class TestRatingSync: |
| """Tests for POST /api/admin/sync/ratings""" |
|
|
| def test_trigger_rating_sync_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test triggering rating sync successfully.""" |
| |
| for i in range(5): |
| rating = SkillRating( |
| id=f"rating_{i}", |
| skill_id=f"skill_{i}", |
| user_id="test_user", |
| tenant_id="test_tenant", |
| rating=5, |
| synced_at=None |
| ) |
| test_db.add(rating) |
| test_db.commit() |
|
|
| |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service._sync_in_progress = False |
| mock_service.get_pending_ratings.return_value = [ |
| MagicMock(id=f"rating_{i}") for i in range(5) |
| ] |
|
|
| |
| async def mock_sync(upload_all=False): |
| return {"success": True, "uploaded": 5, "failed": 0, "skipped": 0} |
|
|
| mock_service.sync_ratings = mock_sync |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/sync/ratings", |
| json={"upload_all": False} |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
| assert data["uploaded"] == 5 |
| assert data["failed"] == 0 |
| assert data["pending_count"] == 5 |
|
|
| def test_trigger_rating_sync_upload_all(self, authenticated_client: TestClient, test_db: Session): |
| """Test sync with upload_all flag.""" |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service._sync_in_progress = False |
| mock_service.get_pending_ratings.return_value = [] |
|
|
| async def mock_sync(upload_all=False): |
| return {"success": True, "uploaded": 10, "failed": 0, "skipped": 0} |
|
|
| mock_service.sync_ratings = mock_sync |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/sync/ratings", |
| json={"upload_all": True} |
| ) |
|
|
| assert response.status_code == 200 |
| assert response.json()["uploaded"] == 10 |
|
|
| def test_trigger_rating_sync_in_progress(self, authenticated_client: TestClient, test_db: Session): |
| """Test sync when already in progress.""" |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service._sync_in_progress = True |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/sync/ratings", |
| json={"upload_all": False} |
| ) |
|
|
| assert response.status_code == 503 |
| data = response.json() |
| assert "RATING_SYNC_IN_PROGRESS" in str(data) |
| assert "already in progress" in data.get("message", "").lower() |
|
|
| def test_trigger_rating_sync_with_failures(self, authenticated_client: TestClient, test_db: Session): |
| """Test sync with some failures.""" |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service._sync_in_progress = False |
| mock_service.get_pending_ratings.return_value = [] |
|
|
| async def mock_sync(upload_all=False): |
| return {"success": True, "uploaded": 3, "failed": 2, "skipped": 1} |
|
|
| mock_service.sync_ratings = mock_sync |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/sync/ratings", |
| json={"upload_all": False} |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["uploaded"] == 3 |
| assert data["failed"] == 2 |
| assert data["skipped"] == 1 |
|
|
|
|
| class TestFailedRatingUploads: |
| """Tests for GET /api/admin/ratings/failed-uploads""" |
|
|
| def test_get_failed_rating_uploads_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test getting failed rating uploads.""" |
| |
| for i in range(3): |
| failed = FailedRatingUpload( |
| id=f"failed_{i}", |
| rating_id=f"rating_{i}", |
| error_message=f"Error {i}", |
| failed_at=datetime.now(timezone.utc), |
| retry_count=i, |
| last_retry_at=datetime.now(timezone.utc) if i > 0 else None |
| ) |
| test_db.add(failed) |
| test_db.commit() |
|
|
| response = authenticated_client.get("/api/admin/ratings/failed-uploads") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert len(data) == 3 |
| assert data[0]["rating_id"] == "rating_0" |
| assert data[0]["retry_count"] == 0 |
|
|
| def test_get_failed_rating_uploads_empty(self, authenticated_client: TestClient): |
| """Test getting failed uploads when none exist.""" |
| response = authenticated_client.get("/api/admin/ratings/failed-uploads") |
|
|
| assert response.status_code == 200 |
| assert response.json() == [] |
|
|
|
|
| class TestRetryFailedRatingUpload: |
| """Tests for POST /api/admin/ratings/failed-uploads/{failed_id}/retry""" |
|
|
| def test_retry_failed_rating_upload_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test retrying failed rating upload successfully.""" |
| |
| rating = SkillRating( |
| id="rating_1", |
| skill_id="skill_1", |
| user_id="test_user", |
| tenant_id="test_tenant", |
| rating=5, |
| synced_at=None |
| ) |
| test_db.add(rating) |
|
|
| |
| failed = FailedRatingUpload( |
| id="failed_1", |
| rating_id="rating_1", |
| error_message="Network error", |
| failed_at=datetime.now(timezone.utc), |
| retry_count=0 |
| ) |
| test_db.add(failed) |
| test_db.commit() |
|
|
| |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
|
|
| async def mock_upload(rating): |
| return {"success": True, "rating_id": "remote_123"} |
|
|
| mock_service.upload_rating = mock_upload |
| mock_service.mark_as_synced = MagicMock() |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post(f"/api/admin/ratings/failed-uploads/failed_1/retry") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
| assert data["retry_triggered"] is True |
| assert "uploaded successfully" in data["message"] |
|
|
| |
| assert test_db.query(FailedRatingUpload).count() == 0 |
|
|
| def test_retry_failed_rating_upload_rating_deleted(self, authenticated_client: TestClient, test_db: Session): |
| """Test retry when rating no longer exists.""" |
| |
| failed = FailedRatingUpload( |
| id="failed_1", |
| rating_id="deleted_rating", |
| error_message="Network error", |
| failed_at=datetime.now(timezone.utc), |
| retry_count=0 |
| ) |
| test_db.add(failed) |
| test_db.commit() |
|
|
| response = authenticated_client.post(f"/api/admin/ratings/failed-uploads/failed_1/retry") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is False |
| assert "no longer exists" in data["message"].lower() |
|
|
| |
| assert test_db.query(FailedRatingUpload).count() == 0 |
|
|
| def test_retry_failed_rating_upload_failed_again(self, authenticated_client: TestClient, test_db: Session): |
| """Test retry that fails again.""" |
| |
| rating = SkillRating( |
| id="rating_1", |
| skill_id="skill_1", |
| user_id="test_user", |
| tenant_id="test_tenant", |
| rating=5 |
| ) |
| test_db.add(rating) |
|
|
| |
| failed = FailedRatingUpload( |
| id="failed_1", |
| rating_id="rating_1", |
| error_message="Network error", |
| failed_at=datetime.now(timezone.utc), |
| retry_count=1 |
| ) |
| test_db.add(failed) |
| test_db.commit() |
|
|
| |
| with patch('core.rating_sync_service.RatingSyncService') as mock_service_class: |
| mock_service = MagicMock() |
|
|
| async def mock_upload(rating): |
| return {"success": False, "error": "Network error"} |
|
|
| mock_service.upload_rating = mock_upload |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post(f"/api/admin/ratings/failed-uploads/failed_1/retry") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is False |
| assert "failed" in data["message"].lower() |
|
|
| |
| test_db.refresh(failed) |
| assert failed.retry_count == 2 |
|
|
| def test_retry_failed_rating_upload_not_found(self, authenticated_client: TestClient): |
| """Test retry with non-existent failed upload.""" |
| response = authenticated_client.post("/api/admin/ratings/failed-uploads/nonexistent/retry") |
|
|
| assert response.status_code == 404 |
|
|
|
|
| |
| |
| |
|
|
| class TestListConflicts: |
| """Tests for GET /api/admin/conflicts""" |
|
|
| def test_list_conflicts_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test listing conflicts successfully.""" |
| |
| for i in range(3): |
| conflict = ConflictLog( |
| id=i + 1, |
| skill_id=f"skill_{i}", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_unresolved_conflicts.return_value = test_db.query(ConflictLog).all() |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert "conflicts" in data |
| assert data["total_count"] == 3 |
| assert data["page"] == 1 |
| assert data["page_size"] == 50 |
|
|
| def test_list_conflicts_filtered_by_severity(self, authenticated_client: TestClient, test_db: Session): |
| """Test listing conflicts filtered by severity.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_unresolved_conflicts.return_value = [] |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts?severity=high") |
|
|
| assert response.status_code == 200 |
| |
| mock_service.get_unresolved_conflicts.assert_called_once() |
|
|
| def test_list_conflicts_filtered_by_type(self, authenticated_client: TestClient, test_db: Session): |
| """Test listing conflicts filtered by type.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_unresolved_conflicts.return_value = [] |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts?conflict_type=version_mismatch") |
|
|
| assert response.status_code == 200 |
|
|
| def test_list_conflicts_paginated(self, authenticated_client: TestClient, test_db: Session): |
| """Test listing conflicts with pagination.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_unresolved_conflicts.return_value = [] |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts?page=2&page_size=20") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["page"] == 2 |
| assert data["page_size"] == 20 |
|
|
| def test_list_conflicts_empty(self, authenticated_client: TestClient, test_db: Session): |
| """Test listing conflicts when none exist.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_unresolved_conflicts.return_value = [] |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["conflicts"] == [] |
| assert data["total_count"] == 0 |
|
|
|
|
| class TestGetConflict: |
| """Tests for GET /api/admin/conflicts/{conflict_id}""" |
|
|
| def test_get_conflict_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test getting conflict by ID.""" |
| conflict = ConflictLog( |
| id=1, |
| skill_id="skill_1", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_conflict_by_id.return_value = conflict |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts/1") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["id"] == 1 |
| assert data["skill_id"] == "skill_1" |
| assert data["conflict_type"] == "version_mismatch" |
| assert data["severity"] == "high" |
|
|
| def test_get_conflict_not_found(self, authenticated_client: TestClient, test_db: Session): |
| """Test getting non-existent conflict.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_conflict_by_id.return_value = None |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.get("/api/admin/conflicts/999") |
|
|
| assert response.status_code == 404 |
|
|
|
|
| class TestResolveConflict: |
| """Tests for POST /api/admin/conflicts/{conflict_id}/resolve""" |
|
|
| def test_resolve_conflict_remote_wins(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving conflict with remote_wins strategy.""" |
| conflict = ConflictLog( |
| id=1, |
| skill_id="skill_1", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| resolved_data = {"skill_id": "skill_1", "version": "2.0"} |
|
|
| |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = resolved_data |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/1/resolve", |
| json={"strategy": "remote_wins", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
| assert data["resolved_data"] == resolved_data |
|
|
| |
| mock_service.resolve_conflict.assert_called_once_with( |
| conflict_id=1, |
| strategy="remote_wins", |
| resolved_by="admin" |
| ) |
|
|
| def test_resolve_conflict_local_wins(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving conflict with local_wins strategy.""" |
| conflict = ConflictLog( |
| id=1, |
| skill_id="skill_1", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| resolved_data = {"skill_id": "skill_1", "version": "1.0"} |
|
|
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = resolved_data |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/1/resolve", |
| json={"strategy": "local_wins", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 200 |
| assert response.json()["success"] is True |
|
|
| def test_resolve_conflict_merge(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving conflict with merge strategy.""" |
| conflict = ConflictLog( |
| id=1, |
| skill_id="skill_1", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| resolved_data = {"skill_id": "skill_1", "version": "2.0", "local_changes": "preserved"} |
|
|
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = resolved_data |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/1/resolve", |
| json={"strategy": "merge", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 200 |
| assert response.json()["success"] is True |
|
|
| def test_resolve_conflict_invalid_strategy(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving conflict with invalid strategy.""" |
| response = authenticated_client.post( |
| "/api/admin/conflicts/1/resolve", |
| json={"strategy": "invalid", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 422 |
| data = response.json() |
| assert "valid_strategies" in str(data).lower() or "strategy" in str(data).lower() |
|
|
| def test_resolve_conflict_updates_cache(self, authenticated_client: TestClient, test_db: Session): |
| """Test that resolving conflict updates skill cache.""" |
| |
| cache = SkillCache( |
| skill_id="skill_1", |
| skill_data={"version": "1.0"}, |
| expires_at=datetime.now(timezone.utc) + timedelta(days=1) |
| ) |
| test_db.add(cache) |
|
|
| conflict = ConflictLog( |
| id=1, |
| skill_id="skill_1", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| resolved_data = {"skill_id": "skill_1", "version": "2.0"} |
|
|
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = resolved_data |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/1/resolve", |
| json={"strategy": "remote_wins", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 200 |
|
|
| |
| test_db.refresh(cache) |
| assert cache.skill_data == resolved_data |
|
|
| def test_resolve_conflict_not_found(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving non-existent conflict.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = None |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/999/resolve", |
| json={"strategy": "remote_wins", "resolved_by": "admin"} |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is False |
|
|
|
|
| class TestBulkResolveConflicts: |
| """Tests for POST /api/admin/conflicts/bulk-resolve""" |
|
|
| def test_bulk_resolve_conflicts_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test bulk resolving conflicts successfully.""" |
| |
| for i in range(3): |
| conflict = ConflictLog( |
| id=i + 1, |
| skill_id=f"skill_{i}", |
| conflict_type="version_mismatch", |
| severity="high", |
| local_data={"version": "1.0"}, |
| remote_data={"version": "2.0"}, |
| created_at=datetime.now(timezone.utc) |
| ) |
| test_db.add(conflict) |
| test_db.commit() |
|
|
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.resolve_conflict.return_value = {"skill_id": "skill_1", "version": "2.0"} |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/bulk-resolve", |
| json={ |
| "conflict_ids": [1, 2, 3], |
| "strategy": "remote_wins", |
| "resolved_by": "admin" |
| } |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["resolved_count"] == 3 |
| assert data["failed_count"] == 0 |
| assert data["success"] is True |
|
|
| def test_bulk_resolve_conflicts_partial_failure(self, authenticated_client: TestClient, test_db: Session): |
| """Test bulk resolve with some failures.""" |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
|
|
| |
| def side_effect(conflict_id, strategy, resolved_by): |
| if conflict_id == 2: |
| return None |
| return {"skill_id": f"skill_{conflict_id}", "version": "2.0"} |
|
|
| mock_service.resolve_conflict.side_effect = side_effect |
| mock_service_class.return_value = mock_service |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/bulk-resolve", |
| json={ |
| "conflict_ids": [1, 2, 3], |
| "strategy": "remote_wins", |
| "resolved_by": "admin" |
| } |
| ) |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["resolved_count"] == 2 |
| assert data["failed_count"] == 1 |
| assert len(data["errors"]) == 1 |
|
|
| def test_bulk_resolve_conflicts_invalid_strategy(self, authenticated_client: TestClient, test_db: Session): |
| """Test bulk resolve with invalid strategy.""" |
| response = authenticated_client.post( |
| "/api/admin/conflicts/bulk-resolve", |
| json={ |
| "conflict_ids": [1, 2, 3], |
| "strategy": "invalid", |
| "resolved_by": "admin" |
| } |
| ) |
|
|
| assert response.status_code == 422 |
|
|
| def test_bulk_resolve_conflicts_too_many_ids(self, authenticated_client: TestClient, test_db: Session): |
| """Test bulk resolve with too many conflict IDs.""" |
| |
| conflict_ids = list(range(101)) |
|
|
| response = authenticated_client.post( |
| "/api/admin/conflicts/bulk-resolve", |
| json={ |
| "conflict_ids": conflict_ids, |
| "strategy": "remote_wins", |
| "resolved_by": "admin" |
| } |
| ) |
|
|
| assert response.status_code == 422 |
| data = response.json() |
| assert "max" in str(data).lower() or "100" in str(data) |
|
|