""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import pytest from fastapi.testclient import TestClient from unittest.mock import patch, MagicMock, AsyncMock from app.database import get_db from fastapi import FastAPI from app.models.user import User from app.models.organization import Organization from app.models.role import Role from app.models.permission import Permission, role_permissions from app.models.jira import JiraToken, AgentJiraConfig from app.models.agent import Agent, AgentType from uuid import UUID, uuid4 from datetime import datetime, timedelta from app.api import jira as jira_router import app.core.oauth_state as oauth_state from urllib.parse import parse_qs, urlparse from app.core.auth import get_current_user, get_current_organization, require_permissions from app.core.exceptions import JiraAuthError from tests.conftest import engine, TestingSessionLocal, create_tables, Base from app.core.config import settings # Create a test FastAPI app app = FastAPI() app.include_router( jira_router.router, prefix="/api/v1/jira", # Match the prefix in main.py tags=["jira"] ) # Override the dependencies def override_get_db(): db = TestingSessionLocal() try: yield db finally: db.close() @pytest.fixture(scope="function") def db(): """Create a fresh database for each test.""" # Drop all tables first Base.metadata.drop_all(bind=engine) # Create tables except enterprise ones create_tables() db = TestingSessionLocal() try: yield db finally: db.close() Base.metadata.drop_all(bind=engine) @pytest.fixture def test_permissions(db) -> list[Permission]: """Create test permissions""" permissions = [] for name in ["manage_organization", "view_organization"]: perm = Permission( name=name, description=f"Test permission for {name}" ) db.add(perm) permissions.append(perm) db.commit() for p in permissions: db.refresh(p) return permissions @pytest.fixture def test_role(db, test_permissions) -> Role: """Create a test role with required permissions""" role = Role( id=1, name="Test Role", description="Test Role Description", is_default=True ) db.add(role) db.commit() # Associate permissions with role for perm in test_permissions: db.execute( role_permissions.insert().values( role_id=role.id, permission_id=perm.id ) ) db.commit() db.refresh(role) return role @pytest.fixture def test_organization(db) -> Organization: """Create a test organization""" org = Organization( id=uuid4(), name="Test Organization", domain="test.com", timezone="UTC", business_hours={"monday": {"start": "09:00", "end": "17:00"}} ) db.add(org) db.commit() db.refresh(org) return org @pytest.fixture def test_user(db, test_organization, test_role) -> User: """Create a test user""" user = User( id=uuid4(), email="test@test.com", hashed_password="hashed_password", full_name="Test User", is_active=True, organization_id=test_organization.id, role_id=test_role.id ) db.add(user) db.commit() db.refresh(user) return user @pytest.fixture def test_agent(db, test_organization) -> Agent: """Create a test agent""" agent = Agent( id=uuid4(), name="Test Agent", organization_id=test_organization.id, instructions="Test instructions", is_active=True, is_default=False, agent_type=AgentType.GENERAL ) db.add(agent) db.commit() db.refresh(agent) return agent @pytest.fixture def test_jira_token(db, test_organization) -> JiraToken: """Create a test Jira token""" token = JiraToken( organization_id=test_organization.id, access_token="test_access_token", refresh_token="test_refresh_token", token_type="Bearer", expires_at=datetime.utcnow() + timedelta(hours=1), cloud_id="test_cloud_id", site_url="https://test.atlassian.net" ) db.add(token) db.commit() db.refresh(token) return token @pytest.fixture def test_expired_jira_token(db, test_organization) -> JiraToken: """Create a test expired Jira token""" token = JiraToken( organization_id=test_organization.id, access_token="test_access_token", refresh_token="test_refresh_token", token_type="Bearer", expires_at=datetime.utcnow() - timedelta(hours=1), cloud_id="test_cloud_id", site_url="https://test.atlassian.net" ) db.add(token) db.commit() db.refresh(token) return token @pytest.fixture def test_agent_jira_config(db, test_agent) -> AgentJiraConfig: """Create a test agent Jira config""" config = AgentJiraConfig( agent_id=str(test_agent.id), enabled=True, project_key="TEST", issue_type_id="10001" ) db.add(config) db.commit() db.refresh(config) return config @pytest.fixture def client(db, test_user, test_organization): """Create a test client with dependencies overridden""" app.dependency_overrides[get_db] = override_get_db # Override auth dependencies def override_get_current_user(): return test_user def override_get_current_organization(): return test_organization def override_require_permissions(*permissions): def _require_permissions(): return test_user return _require_permissions app.dependency_overrides[get_current_user] = override_get_current_user app.dependency_overrides[get_current_organization] = override_get_current_organization app.dependency_overrides[require_permissions] = override_require_permissions return TestClient(app) def test_jira_status_connected(client, test_jira_token): """Test getting Jira status when connected""" with patch('app.api.jira.jira_service.validate_token', return_value=True): response = client.get("/api/v1/jira/status") assert response.status_code == 200 assert response.json() == { "connected": True, "site_url": "https://test.atlassian.net" } def test_jira_status_not_connected(client, db): """Test getting Jira status when not connected""" response = client.get("/api/v1/jira/status") assert response.status_code == 200 assert response.json() == { "connected": False } def test_jira_status_expired_token_refresh_success(client, test_expired_jira_token): """Test getting Jira status with expired token that gets refreshed successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=False): with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.return_value = { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "Bearer", "expires_at": datetime.utcnow() + timedelta(hours=1) } response = client.get("/api/v1/jira/status") assert response.status_code == 200 assert response.json() == { "connected": True, "site_url": "https://test.atlassian.net" } mock_refresh.assert_called_once_with("test_refresh_token") def test_jira_status_expired_token_refresh_failure(client, test_expired_jira_token): """Test getting Jira status with expired token that fails to refresh""" with patch('app.api.jira.jira_service.validate_token', return_value=False): with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.side_effect = Exception("Failed to refresh token") response = client.get("/api/v1/jira/status") assert response.status_code == 200 assert response.json() == { "connected": False, "site_url": None } mock_refresh.assert_called_once_with("test_refresh_token") def test_disconnect_jira_success(client, test_jira_token, test_agent_jira_config): """Test disconnecting Jira successfully""" response = client.delete("/api/v1/jira/disconnect") assert response.status_code == 200 assert response.json() == {"message": "Jira disconnected successfully"} def test_disconnect_jira_not_connected(client): """Test disconnecting Jira when not connected""" response = client.delete("/api/v1/jira/disconnect") assert response.status_code == 404 assert response.json() == {"detail": "No Jira connection found"} @pytest.fixture def inprocess_oauth_state(monkeypatch): """The in-process state store, whether or not a Redis is reachable here.""" monkeypatch.setattr(oauth_state, "get_redis", lambda: None) oauth_state._fallback.clear() def test_authorize_jira(client, inprocess_oauth_state): """The redirect carries a state the callback can redeem once.""" response = client.get("/api/v1/jira/authorize", follow_redirects=False) assert response.status_code == 307 location = response.headers["location"] assert location.startswith("https://auth.atlassian.com/authorize?") state = parse_qs(urlparse(location).query)["state"][0] assert oauth_state.pop_state("jira", state) is not None assert oauth_state.pop_state("jira", state) is None def test_jira_oauth_callback_success(client, db, test_organization, inprocess_oauth_state): """Test successful Jira OAuth callback""" state = "test_state" oauth_state.store_state("jira", state, str(test_organization.id)) with patch('app.api.jira.jira_service.exchange_code_for_token', new_callable=AsyncMock) as mock_exchange: mock_exchange.return_value = { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "Bearer", "expires_at": datetime.utcnow() + timedelta(hours=1) } with patch('app.api.jira.jira_service.get_cloud_id', new_callable=AsyncMock) as mock_cloud_id: mock_cloud_id.return_value = { "cloud_id": "test_cloud_id", "site_url": "https://test.atlassian.net" } response = client.get(f"/api/v1/jira/callback?code=test_code&state={state}", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == f"{settings.FRONTEND_URL}/settings/integrations?status=success" # The state is single-use. assert oauth_state.pop_state("jira", state) is None token = db.query(JiraToken).filter(JiraToken.organization_id == test_organization.id).first() assert token is not None assert token.access_token == "new_access_token" assert token.refresh_token == "new_refresh_token" def test_jira_oauth_callback_error(client, inprocess_oauth_state): """Test Jira OAuth callback with error""" response = client.get("/api/v1/jira/callback?error=access_denied&error_description=User%20denied%20access", follow_redirects=False) assert response.status_code == 307 assert "status=failure" in response.headers["location"] assert "reason=access_denied" in response.headers["location"] def test_jira_oauth_callback_invalid_state(client, inprocess_oauth_state): """Test Jira OAuth callback with invalid state""" response = client.get("/api/v1/jira/callback?code=test_code&state=invalid_state", follow_redirects=False) assert response.status_code == 307 assert "status=failure" in response.headers["location"] assert "reason=invalid_state" in response.headers["location"] def test_jira_oauth_callback_exception(client, test_organization, inprocess_oauth_state): """Test Jira OAuth callback with exception""" state = "test_state" oauth_state.store_state("jira", state, str(test_organization.id)) with patch('app.api.jira.jira_service.exchange_code_for_token', new_callable=AsyncMock) as mock_exchange: mock_exchange.side_effect = Exception("Test exception") response = client.get(f"/api/v1/jira/callback?code=test_code&state={state}", follow_redirects=False) assert response.status_code == 307 assert "status=failure" in response.headers["location"] assert "reason=Test_exception" in response.headers["location"] assert oauth_state.pop_state("jira", state) is None def test_refresh_jira_token_success(client, test_jira_token): """Test refreshing Jira token successfully""" with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.return_value = { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "Bearer", "expires_at": datetime.utcnow() + timedelta(hours=1) } response = client.get("/api/v1/jira/refresh") assert response.status_code == 200 assert response.json() == {"message": "Token refreshed successfully"} mock_refresh.assert_called_once_with("test_refresh_token") def test_refresh_jira_token_not_found(client): """Test refreshing Jira token when not found""" response = client.get("/api/v1/jira/refresh") assert response.status_code == 404 assert response.json() == {"detail": "No Jira token found"} def test_refresh_jira_token_error(client, test_jira_token): """Test refreshing Jira token with error""" with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.side_effect = Exception("Failed to refresh token") response = client.get("/api/v1/jira/refresh") assert response.status_code == 400 assert response.json() == {"detail": "Failed to refresh token"} def test_get_jira_projects_success(client, test_jira_token): """Test getting Jira projects successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=True): with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects: mock_projects.return_value = [ {"id": "10000", "key": "TEST", "name": "Test Project"}, {"id": "10001", "key": "DEMO", "name": "Demo Project"} ] response = client.get("/api/v1/jira/projects") assert response.status_code == 200 assert response.json() == [ {"id": "10000", "key": "TEST", "name": "Test Project"}, {"id": "10001", "key": "DEMO", "name": "Demo Project"} ] def test_get_jira_projects_not_connected(client): """Test getting Jira projects when not connected""" response = client.get("/api/v1/jira/projects") assert response.status_code == 404 assert response.json() == {"detail": "No Jira connection found"} def test_get_jira_projects_expired_token_refresh_success(client, test_expired_jira_token): """Test getting Jira projects with expired token that gets refreshed successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=False): with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.return_value = { "access_token": "new_access_token", "refresh_token": "new_refresh_token", "token_type": "Bearer", "expires_at": datetime.utcnow() + timedelta(hours=1) } with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects: mock_projects.return_value = [ {"id": "10000", "key": "TEST", "name": "Test Project"} ] response = client.get("/api/v1/jira/projects") assert response.status_code == 200 assert response.json() == [ {"id": "10000", "key": "TEST", "name": "Test Project"} ] def test_get_jira_projects_expired_token_refresh_failure(client, test_expired_jira_token): """Test getting Jira projects with expired token that fails to refresh""" with patch('app.api.jira.jira_service.validate_token', return_value=False): with patch('app.api.jira.jira_service.refresh_token', new_callable=AsyncMock) as mock_refresh: mock_refresh.side_effect = Exception("Failed to refresh token") response = client.get("/api/v1/jira/projects") assert response.status_code == 401 assert response.json() == {"detail": "Jira token expired and could not be refreshed"} def test_get_jira_projects_error(client, test_jira_token): """Test getting Jira projects with error""" with patch('app.api.jira.jira_service.validate_token', return_value=True): with patch('app.api.jira.jira_service.get_projects', new_callable=AsyncMock) as mock_projects: mock_projects.side_effect = Exception("Failed to get projects") response = client.get("/api/v1/jira/projects") assert response.status_code == 500 assert response.json() == {"detail": "Failed to get Jira projects"} def test_get_jira_issue_types_success(client, test_jira_token): """Test getting Jira issue types successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=True): with patch('app.api.jira.jira_service.get_issue_types', new_callable=AsyncMock) as mock_issue_types: mock_issue_types.return_value = [ {"id": "10001", "name": "Bug", "description": "A bug"}, {"id": "10002", "name": "Task", "description": "A task"} ] response = client.get("/api/v1/jira/projects/TEST/issue-types") assert response.status_code == 200 assert response.json() == [ {"id": "10001", "name": "Bug", "description": "A bug"}, {"id": "10002", "name": "Task", "description": "A task"} ] def test_get_jira_priorities_success(client, test_jira_token): """Test getting Jira priorities successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=True): with patch('app.api.jira.jira_service.get_priorities', new_callable=AsyncMock) as mock_priorities: mock_priorities.return_value = [ {"id": "1", "name": "Highest", "description": "Highest priority", "iconUrl": "https://example.com/highest.png"}, {"id": "2", "name": "High", "description": "High priority", "iconUrl": "https://example.com/high.png"} ] response = client.get("/api/v1/jira/priorities") assert response.status_code == 200 assert response.json() == [ {"id": "1", "name": "Highest", "description": "Highest priority", "iconUrl": "https://example.com/highest.png"}, {"id": "2", "name": "High", "description": "High priority", "iconUrl": "https://example.com/high.png"} ] def test_check_priority_availability_success(client, test_jira_token): """Test checking priority availability successfully""" with patch('app.api.jira.jira_service.validate_token', return_value=True): with patch('app.api.jira.jira_service.is_priority_available', new_callable=AsyncMock) as mock_priority: mock_priority.return_value = True response = client.get("/api/v1/jira/projects/TEST/issue-types/10001/has-priority") assert response.status_code == 200 assert response.json() == {"hasPriority": True} def test_create_jira_issue_success(client, test_jira_token): """Test creating a Jira issue successfully""" with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create: mock_create.return_value = { "id": "10000", "key": "TEST-123", "self": "https://test.atlassian.net/rest/api/3/issue/TEST-123" } response = client.post( "/api/v1/jira/issues", json={ "projectKey": "TEST", "issueTypeId": "10001", "summary": "Test Issue", "description": "This is a test issue", "priority": "High" } ) assert response.status_code == 200 assert response.json() == { "id": "10000", "key": "TEST-123", "self": "https://test.atlassian.net/rest/api/3/issue/TEST-123" } def test_create_jira_issue_auth_error(client, test_jira_token): """Test creating a Jira issue with authentication error""" with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create: mock_create.side_effect = JiraAuthError("Jira authentication failed") response = client.post( "/api/v1/jira/issues", json={ "projectKey": "TEST", "issueTypeId": "10001", "summary": "Test Issue", "description": "This is a test issue" } ) assert response.status_code == 401 assert response.json() == {"detail": "Jira authentication failed"} def test_create_jira_issue_error(client, test_jira_token): """Test creating a Jira issue with error""" with patch('app.api.jira.jira_service.create_issue', new_callable=AsyncMock) as mock_create: mock_create.side_effect = Exception("Failed to create issue") response = client.post( "/api/v1/jira/issues", json={ "projectKey": "TEST", "issueTypeId": "10001", "summary": "Test Issue", "description": "This is a test issue" } ) assert response.status_code == 500 assert response.json() == {"detail": "Failed to create Jira issue"} def test_save_agent_jira_config_success(client, test_agent, test_jira_token): """Test saving agent Jira config successfully""" response = client.post( f"/api/v1/jira/agent-config/{test_agent.id}", json={ "enabled": True, "projectKey": "TEST", "issueTypeId": "10001" } ) assert response.status_code == 200 assert response.json() == {"message": "Agent Jira configuration saved successfully"} def test_save_agent_jira_config_update_existing(client, test_agent, test_agent_jira_config, test_jira_token): """Test updating existing agent Jira config""" response = client.post( f"/api/v1/jira/agent-config/{test_agent.id}", json={ "enabled": False, "projectKey": "DEMO", "issueTypeId": "10002" } ) assert response.status_code == 200 assert response.json() == {"message": "Agent Jira configuration saved successfully"} def test_save_agent_jira_config_no_jira_connection(client, test_agent): """Test saving agent Jira config with no Jira connection""" response = client.post( f"/api/v1/jira/agent-config/{test_agent.id}", json={ "enabled": True, "projectKey": "TEST", "issueTypeId": "10001" } ) assert response.status_code == 400 assert response.json() == {"detail": "Cannot enable Jira integration: Jira is not connected"} def test_get_agent_jira_config_exists(client, test_agent, test_agent_jira_config): """Test getting agent Jira config when it exists""" response = client.get(f"/api/v1/jira/agent-config/{test_agent.id}") assert response.status_code == 200 assert response.json() == { "enabled": True, "projectKey": "TEST", "issueTypeId": "10001" } def test_get_agent_jira_config_not_exists(client, test_agent): """Test getting agent Jira config when it doesn't exist""" response = client.get(f"/api/v1/jira/agent-config/{test_agent.id}") assert response.status_code == 200 assert response.json() == { "enabled": False, "projectKey": None, "issueTypeId": None }