| """ |
| 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 |
| |
| Note: Part 1 tests (lines 1-545) should be added in a separate plan (172-03). |
| """ |
|
|
| 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 |
| import uuid |
|
|
| |
| from api.admin_routes import router |
|
|
| |
| from core.database import Base |
|
|
| |
| from core.models import ( |
| User, WebSocketState, FailedRatingUpload, |
| SkillRating, ConflictLog, SkillCache |
| ) |
|
|
| |
| 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 |
| ) |
|
|
| |
| TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) |
| db = TestingSessionLocal() |
|
|
| |
| |
| db.execute(""" |
| CREATE TABLE users ( |
| id VARCHAR PRIMARY KEY, |
| email VARCHAR NOT NULL, |
| name VARCHAR, |
| role VARCHAR, |
| tenant_id VARCHAR, |
| is_active BOOLEAN DEFAULT 1 |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE websocket_state ( |
| id INTEGER PRIMARY KEY, |
| connected BOOLEAN DEFAULT 0, |
| ws_url VARCHAR, |
| last_connected_at TIMESTAMP, |
| last_message_at TIMESTAMP, |
| disconnect_reason VARCHAR, |
| reconnect_attempts INTEGER DEFAULT 0, |
| consecutive_failures INTEGER DEFAULT 0, |
| max_reconnect_attempts INTEGER DEFAULT 10, |
| fallback_to_polling BOOLEAN DEFAULT 0, |
| fallback_started_at TIMESTAMP, |
| next_ws_attempt_at TIMESTAMP, |
| rate_limit_messages_per_sec INTEGER DEFAULT 100, |
| websocket_enabled BOOLEAN DEFAULT 1 |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE skill_ratings ( |
| id VARCHAR PRIMARY KEY, |
| skill_id VARCHAR NOT NULL, |
| user_id VARCHAR NOT NULL, |
| tenant_id VARCHAR NOT NULL, |
| rating INTEGER NOT NULL, |
| review TEXT, |
| synced_at TIMESTAMP |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE failed_rating_uploads ( |
| id VARCHAR PRIMARY KEY, |
| rating_id VARCHAR NOT NULL, |
| error_message TEXT NOT NULL, |
| failed_at TIMESTAMP NOT NULL, |
| last_retry_at TIMESTAMP, |
| retry_count INTEGER DEFAULT 0, |
| max_retries INTEGER DEFAULT 3, |
| tenant_id VARCHAR NOT NULL |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE skills ( |
| id VARCHAR PRIMARY KEY, |
| name VARCHAR NOT NULL |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE tenants ( |
| id VARCHAR PRIMARY KEY, |
| name VARCHAR NOT NULL |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE conflict_log ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| skill_id VARCHAR NOT NULL, |
| conflict_type VARCHAR NOT NULL, |
| severity VARCHAR NOT NULL, |
| local_data TEXT NOT NULL, |
| remote_data TEXT NOT NULL, |
| resolution_strategy VARCHAR, |
| resolved_data TEXT, |
| resolved_at TIMESTAMP, |
| resolved_by VARCHAR, |
| created_at TIMESTAMP NOT NULL, |
| updated_at TIMESTAMP, |
| tenant_id VARCHAR NOT NULL |
| ) |
| """) |
|
|
| |
| db.execute(""" |
| CREATE TABLE skill_cache ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| skill_id VARCHAR NOT NULL UNIQUE, |
| skill_data TEXT NOT NULL, |
| expires_at TIMESTAMP NOT NULL, |
| created_at TIMESTAMP, |
| updated_at TIMESTAMP, |
| tenant_id VARCHAR NOT NULL, |
| hit_count INTEGER DEFAULT 0, |
| last_hit_at TIMESTAMP |
| ) |
| """) |
|
|
| db.commit() |
|
|
| yield db |
|
|
| |
| db.close() |
|
|
|
|
| @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_id = str(uuid.uuid4()) |
| user = User( |
| id=user_id, |
| 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.""" |
| |
| now = datetime.now(timezone.utc) |
| test_db.execute(""" |
| INSERT INTO websocket_state |
| (id, connected, ws_url, last_connected_at, last_message_at, reconnect_attempts, consecutive_failures, fallback_to_polling) |
| VALUES (1, 1, 'wss://api.example.com/ws', :now, :now, 3, 0, 0) |
| """, {"now": now}) |
| 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 |
|
|
| def test_get_websocket_status_disconnected(self, authenticated_client: TestClient, test_db: Session): |
| """Test WebSocket status when disconnected.""" |
| test_db.execute(""" |
| INSERT INTO websocket_state |
| (id, connected, reconnect_attempts, consecutive_failures, disconnect_reason, fallback_to_polling) |
| VALUES (1, 0, 5, 3, 'connection_lost', 1) |
| """) |
| 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 |
|
|
|
|
| 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.""" |
| |
| test_db.execute(""" |
| INSERT INTO websocket_state |
| (id, connected, reconnect_attempts, consecutive_failures, fallback_to_polling) |
| VALUES (1, 0, 5, 3, 1) |
| """) |
| 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 |
|
|
| |
| result = test_db.execute("SELECT reconnect_attempts, consecutive_failures, fallback_to_polling FROM websocket_state WHERE id = 1").fetchone() |
| assert result[0] == 0 |
| assert result[1] == 0 |
| assert result[2] == 0 |
|
|
| 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 |
|
|
| |
| result = test_db.execute("SELECT id FROM websocket_state").fetchone() |
| assert result is not None |
| assert result[0] == 1 |
|
|
|
|
| class TestWebSocketDisable: |
| """Tests for POST /api/admin/websocket/disable""" |
|
|
| def test_disable_websocket_success(self, authenticated_client: TestClient, test_db: Session): |
| """Test disabling WebSocket.""" |
| test_db.execute(""" |
| INSERT INTO websocket_state |
| (id, connected, websocket_enabled, fallback_to_polling) |
| VALUES (1, 1, 1, 0) |
| """) |
| 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 |
|
|
| |
| result = test_db.execute("SELECT connected, disconnect_reason FROM websocket_state WHERE id = 1").fetchone() |
| assert result[0] == 0 |
| assert result[1] == "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 |
|
|
| |
| result = test_db.execute("SELECT id FROM websocket_state").fetchone() |
| assert result 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.""" |
| now = datetime.now(timezone.utc) + timedelta(hours=1) |
| test_db.execute(""" |
| INSERT INTO websocket_state |
| (id, fallback_to_polling, next_ws_attempt_at, reconnect_attempts) |
| VALUES (1, 1, :now, 5) |
| """, {"now": now}) |
| 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 |
|
|
| |
| result = test_db.execute("SELECT fallback_to_polling, next_ws_attempt_at, reconnect_attempts FROM websocket_state WHERE id = 1").fetchone() |
| assert result[0] == 0 |
| assert result[1] is None |
| assert result[2] == 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 |
|
|
| |
| result = test_db.execute("SELECT id FROM websocket_state").fetchone() |
| assert result 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): |
| test_db.execute(""" |
| INSERT INTO skill_ratings |
| (id, skill_id, user_id, tenant_id, rating, synced_at) |
| VALUES (:id, :skill_id, 'test_user', 'test_tenant', 5, NULL) |
| """, {"id": f"rating_{i}", "skill_id": f"skill_{i}"}) |
| 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 |
|
|
| 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 |
|
|
| 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): |
| test_db.execute(""" |
| INSERT INTO failed_rating_uploads |
| (id, rating_id, error_message, failed_at, retry_count, tenant_id) |
| VALUES (:id, :rating_id, :error, :now, :retry_count, 'test_tenant') |
| """, { |
| "id": f"failed_{i}", |
| "rating_id": f"rating_{i}", |
| "error": f"Error {i}", |
| "now": datetime.now(timezone.utc), |
| "retry_count": i |
| }) |
| test_db.commit() |
|
|
| response = authenticated_client.get("/api/admin/ratings/failed-uploads") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert len(data) == 3 |
|
|
| 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.""" |
| |
| test_db.execute(""" |
| INSERT INTO skill_ratings |
| (id, skill_id, user_id, tenant_id, rating, synced_at) |
| VALUES ('rating_1', 'skill_1', 'test_user', 'test_tenant', 5, NULL) |
| """) |
|
|
| |
| test_db.execute(""" |
| INSERT INTO failed_rating_uploads |
| (id, rating_id, error_message, failed_at, retry_count, tenant_id) |
| VALUES ('failed_1', 'rating_1', 'Network error', :now, 0, 'test_tenant') |
| """, {"now": datetime.now(timezone.utc)}) |
| 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("/api/admin/ratings/failed-uploads/failed_1/retry") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is True |
|
|
| |
| result = test_db.execute("SELECT COUNT(*) FROM failed_rating_uploads").fetchone() |
| assert result[0] == 0 |
|
|
| def test_retry_failed_rating_upload_rating_deleted(self, authenticated_client: TestClient, test_db: Session): |
| """Test retry when rating no longer exists.""" |
| |
| test_db.execute(""" |
| INSERT INTO failed_rating_uploads |
| (id, rating_id, error_message, failed_at, retry_count, tenant_id) |
| VALUES ('failed_1', 'deleted_rating', 'Network error', :now, 0, 'test_tenant') |
| """, {"now": datetime.now(timezone.utc)}) |
| test_db.commit() |
|
|
| response = authenticated_client.post("/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() |
|
|
| def test_retry_failed_rating_upload_failed_again(self, authenticated_client: TestClient, test_db: Session): |
| """Test retry that fails again.""" |
| |
| test_db.execute(""" |
| INSERT INTO skill_ratings |
| (id, skill_id, user_id, tenant_id, rating, synced_at) |
| VALUES ('rating_1', 'skill_1', 'test_user', 'test_tenant', 5, NULL) |
| """) |
|
|
| |
| test_db.execute(""" |
| INSERT INTO failed_rating_uploads |
| (id, rating_id, error_message, failed_at, retry_count, tenant_id) |
| VALUES ('failed_1', 'rating_1', 'Network error', :now, 1, 'test_tenant') |
| """, {"now": datetime.now(timezone.utc)}) |
| 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("/api/admin/ratings/failed-uploads/failed_1/retry") |
|
|
| assert response.status_code == 200 |
| data = response.json() |
| assert data["success"] is False |
|
|
| 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): |
| test_db.execute(""" |
| INSERT INTO conflict_log |
| (id, skill_id, conflict_type, severity, local_data, remote_data, created_at, tenant_id) |
| VALUES (:id, :skill_id, 'version_mismatch', 'high', '{"version": "1.0"}', '{"version": "2.0"}', :now, 'test_tenant') |
| """, {"id": i + 1, "skill_id": f"skill_{i}", "now": datetime.now(timezone.utc)}) |
| test_db.commit() |
|
|
| |
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
|
|
| |
| mock_conflicts = [] |
| for i in range(3): |
| c = MagicMock() |
| c.id = i + 1 |
| c.skill_id = f"skill_{i}" |
| c.conflict_type = "version_mismatch" |
| c.severity = "high" |
| c.local_data = {"version": "1.0"} |
| c.remote_data = {"version": "2.0"} |
| c.resolution_strategy = None |
| c.resolved_data = None |
| c.resolved_at = None |
| c.resolved_by = None |
| c.created_at = datetime.now(timezone.utc) |
| mock_conflicts.append(c) |
|
|
| mock_service.get_unresolved_conflicts.return_value = mock_conflicts |
| 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 |
|
|
| 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.""" |
| |
| mock_conflict = MagicMock() |
| mock_conflict.id = 1 |
| mock_conflict.skill_id = "skill_1" |
| mock_conflict.conflict_type = "version_mismatch" |
| mock_conflict.severity = "high" |
| mock_conflict.local_data = {"version": "1.0"} |
| mock_conflict.remote_data = {"version": "2.0"} |
| mock_conflict.created_at = datetime.now(timezone.utc) |
|
|
| with patch('core.conflict_resolution_service.ConflictResolutionService') as mock_service_class: |
| mock_service = MagicMock() |
| mock_service.get_conflict_by_id.return_value = mock_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" |
|
|
| 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.""" |
| 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 |
|
|
| def test_resolve_conflict_local_wins(self, authenticated_client: TestClient, test_db: Session): |
| """Test resolving conflict with local_wins strategy.""" |
| 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.""" |
| 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 |
|
|
| 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.""" |
| 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 |
|
|
| 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 |
|
|