Files
replypier/backend/tests/service/test_jira.py
T
WangShen007 2554eb7bc7 test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
2026-10-08 22:35:00 +08:00

817 lines
28 KiB
Python

"""
Copyright 2024-2026 ChatterMate
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 datetime import datetime, timedelta
from unittest.mock import patch, MagicMock, AsyncMock
from app.core.config import settings
from app.services.jira import JiraService
from app.models.jira import JiraToken
from app.core.exceptions import JiraAuthError
@pytest.fixture
def jira_service(monkeypatch):
"""Create a JiraService instance for testing.
Patches `settings`, not os.environ: the credentials are read from the
settings singleton, which is populated once at import, so setting the env
var after that point would never reach the service."""
monkeypatch.setattr(settings, "JIRA_CLIENT_ID", "test_client_id")
monkeypatch.setattr(settings, "JIRA_CLIENT_SECRET", "test_client_secret")
monkeypatch.setattr(settings, "JIRA_REDIRECT_URI", "https://example.com/callback")
return JiraService()
@pytest.fixture
def mock_token():
"""Create a mock JiraToken for testing."""
token = MagicMock(spec=JiraToken)
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.expires_at = datetime.utcnow() + timedelta(hours=1)
token.cloud_id = "test_cloud_id"
return token
@pytest.fixture
def expired_token():
"""Create a mock expired JiraToken for testing."""
token = MagicMock(spec=JiraToken)
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.expires_at = datetime.utcnow() - timedelta(hours=1)
token.cloud_id = "test_cloud_id"
return token
def test_get_authorization_url(jira_service):
"""Test generating the authorization URL."""
state = "test_state"
url = jira_service.get_authorization_url(state)
# Check that the URL contains all required parameters
assert "auth.atlassian.com/authorize" in url
assert "client_id=test_client_id" in url
assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in url
assert f"state={state}" in url
assert "response_type=code" in url
assert "scope=" in url
def test_validate_token_valid(jira_service, mock_token):
"""Test validating a valid token."""
assert jira_service.validate_token(mock_token) is True
def test_validate_token_expired(jira_service, expired_token):
"""Test validating an expired token."""
assert jira_service.validate_token(expired_token) is False
def test_validate_token_none(jira_service):
"""Test validating a None token."""
assert jira_service.validate_token(None) is False
@pytest.mark.asyncio
async def test_exchange_code_for_token_success(jira_service):
"""Test exchanging code for token successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.exchange_code_for_token("test_code")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "new_refresh_token"
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_exchange_code_for_token_failure(jira_service):
"""Test exchanging code for token with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid code"
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to exchange code for token"):
await jira_service.exchange_code_for_token("invalid_code")
@pytest.mark.asyncio
async def test_refresh_token_success(jira_service):
"""Test refreshing token successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.refresh_token("test_refresh_token")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "new_refresh_token"
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_refresh_token_without_new_refresh_token(jira_service):
"""Test refreshing token when no new refresh token is provided."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.refresh_token("test_refresh_token")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "test_refresh_token" # Should keep the old refresh token
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_refresh_token_failure(jira_service):
"""Test refreshing token with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid refresh token"
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to refresh token"):
await jira_service.refresh_token("invalid_refresh_token")
@pytest.mark.asyncio
async def test_get_cloud_id_success(jira_service):
"""Test getting cloud ID successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"id": "test_cloud_id",
"url": "https://example.atlassian.net"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_cloud_id("test_access_token")
assert result["cloud_id"] == "test_cloud_id"
assert result["site_url"] == "https://example.atlassian.net"
@pytest.mark.asyncio
async def test_get_cloud_id_failure(jira_service):
"""Test getting cloud ID with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira Cloud ID"):
await jira_service.get_cloud_id("invalid_access_token")
@pytest.mark.asyncio
async def test_get_cloud_id_empty_resources(jira_service):
"""Test getting cloud ID with empty resources."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="No Jira Cloud instances found"):
await jira_service.get_cloud_id("test_access_token")
@pytest.mark.asyncio
async def test_get_projects_success(jira_service):
"""Test getting projects successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"id": "10000",
"key": "TEST",
"name": "Test Project"
},
{
"id": "10001",
"key": "DEMO",
"name": "Demo Project"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_projects("test_access_token", "test_cloud_id")
assert len(result) == 2
assert result[0]["id"] == "10000"
assert result[0]["key"] == "TEST"
assert result[0]["name"] == "Test Project"
assert result[1]["id"] == "10001"
assert result[1]["key"] == "DEMO"
assert result[1]["name"] == "Demo Project"
@pytest.mark.asyncio
async def test_get_projects_failure(jira_service):
"""Test getting projects with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira projects"):
await jira_service.get_projects("invalid_access_token", "test_cloud_id")
@pytest.mark.asyncio
async def test_get_issue_types_success(jira_service):
"""Test getting issue types successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "10000",
"key": "TEST",
"name": "Test Project",
"issueTypes": [
{
"id": "10001",
"name": "Bug",
"description": "A bug in the system",
"iconUrl": "https://example.com/bug.png",
"subtask": False
},
{
"id": "10002",
"name": "Task",
"description": "A task to be completed",
"iconUrl": "https://example.com/task.png",
"subtask": False
},
{
"id": "10003",
"name": "Sub-task",
"description": "A sub-task",
"iconUrl": "https://example.com/subtask.png",
"subtask": True
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_issue_types("test_access_token", "test_cloud_id", "TEST")
assert len(result) == 2 # Should exclude the sub-task
assert result[0]["id"] == "10001"
assert result[0]["name"] == "Bug"
assert result[1]["id"] == "10002"
assert result[1]["name"] == "Task"
@pytest.mark.asyncio
async def test_get_issue_types_failure(jira_service):
"""Test getting issue types with failure."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Project not found"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira project"):
await jira_service.get_issue_types("test_access_token", "test_cloud_id", "INVALID")
@pytest.mark.asyncio
async def test_create_issue_success(jira_service):
"""Test creating an issue successfully."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() + timedelta(hours=1)
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
issue_data.projectKey = "TEST"
issue_data.issueTypeId = "10001"
issue_data.summary = "Test Issue"
issue_data.description = "This is a test issue"
issue_data.priority = "High"
# Mock create_issue_internal
with patch.object(jira_service, "create_issue_internal", new_callable=AsyncMock) as mock_create_internal:
mock_create_internal.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
result = await jira_service.create_issue(organization, db, issue_data)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify create_issue_internal was called with correct parameters
mock_create_internal.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"High"
)
@pytest.mark.asyncio
async def test_create_issue_no_token(jira_service):
"""Test creating an issue when no token is found."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
# Mock issue data
issue_data = MagicMock()
with pytest.raises(JiraAuthError, match="No Jira connection found"):
await jira_service.create_issue(organization, db, issue_data)
@pytest.mark.asyncio
async def test_create_issue_expired_token(jira_service):
"""Test creating an issue with an expired token that gets refreshed."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "old_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() - timedelta(hours=1) # Expired
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
issue_data.projectKey = "TEST"
issue_data.issueTypeId = "10001"
issue_data.summary = "Test Issue"
issue_data.description = "This is a test issue"
issue_data.priority = "High"
# Mock refresh_token
with patch.object(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)
}
# Mock create_issue_internal
with patch.object(jira_service, "create_issue_internal", new_callable=AsyncMock) as mock_create_internal:
mock_create_internal.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
result = await jira_service.create_issue(organization, db, issue_data)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify refresh_token was called
mock_refresh.assert_called_once_with("test_refresh_token")
# Verify create_issue_internal was called with the new access token
mock_create_internal.assert_called_once_with(
"new_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"High"
)
# Verify db.commit was called to save the new token
db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_create_issue_refresh_failure(jira_service):
"""Test creating an issue when token refresh fails."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "old_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() - timedelta(hours=1) # Expired
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
# Mock refresh_token to raise an exception
with patch.object(jira_service, "refresh_token", new_callable=AsyncMock) as mock_refresh:
mock_refresh.side_effect = JiraAuthError("Failed to refresh token")
with pytest.raises(JiraAuthError, match="Jira token expired and could not be refreshed"):
await jira_service.create_issue(organization, db, issue_data)
@pytest.mark.asyncio
async def test_create_issue_internal_success(jira_service):
"""Test creating an issue internally successfully."""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
# Mock is_priority_available to return True
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = True
with patch("requests.post", return_value=mock_response):
result = await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify is_priority_available was called
mock_priority_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
@pytest.mark.asyncio
async def test_create_issue_internal_priority_not_available(jira_service):
"""Test creating an issue when priority field is not available."""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
# Mock is_priority_available to return False
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = False
with patch("requests.post", return_value=mock_response):
result = await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify is_priority_available was called
mock_priority_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
@pytest.mark.asyncio
async def test_create_issue_internal_failure(jira_service):
"""Test creating an issue internally with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid project key"
# Mock is_priority_available to return True
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = True
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to create Jira issue"):
await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"INVALID",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
@pytest.mark.asyncio
async def test_get_priorities_success(jira_service):
"""Test getting priorities successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.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"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_priorities("test_access_token", "test_cloud_id")
assert len(result) == 2
assert result[0]["id"] == "1"
assert result[0]["name"] == "Highest"
assert result[1]["id"] == "2"
assert result[1]["name"] == "High"
@pytest.mark.asyncio
async def test_get_priorities_failure(jira_service):
"""Test getting priorities with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira priorities"):
await jira_service.get_priorities("invalid_access_token", "test_cloud_id")
@pytest.mark.asyncio
async def test_check_field_availability_success(jira_service):
"""Test checking field availability successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": [
{
"fields": {
"priority": {
"allowedValues": [
{"name": "Highest"},
{"name": "High"}
]
}
}
}
]
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is True
@pytest.mark.asyncio
async def test_check_field_availability_field_not_found(jira_service):
"""Test checking field availability when field is not found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": [
{
"fields": {
"summary": {},
"description": {}
}
}
]
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_no_projects(jira_service):
"""Test checking field availability when no projects are found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": []
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_no_issue_types(jira_service):
"""Test checking field availability when no issue types are found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": []
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_api_error(jira_service):
"""Test checking field availability when API returns an error."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Project not found"
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"INVALID",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_is_priority_available(jira_service):
"""Test checking if priority field is available."""
with patch.object(jira_service, "check_field_availability", new_callable=AsyncMock) as mock_check:
mock_check.return_value = True
result = await jira_service.is_priority_available(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
assert result is True
mock_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
@pytest.fixture
def reload_jira_urls(monkeypatch):
"""Re-import app.services.jira under the given Atlassian bases, then restore it."""
import importlib
import app.services.jira as jira_module
def _reload(**env):
for name in ("JIRA_API_BASE", "JIRA_AUTH_BASE"):
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
return importlib.reload(jira_module)
yield _reload
for name in ("JIRA_API_BASE", "JIRA_AUTH_BASE"):
monkeypatch.delenv(name, raising=False)
importlib.reload(jira_module)
def test_jira_urls_default_to_atlassian(reload_jira_urls):
jira = reload_jira_urls()
assert jira.jira_authorize_url() == "https://auth.atlassian.com/authorize"
assert jira.jira_token_url() == "https://auth.atlassian.com/oauth/token"
assert jira.jira_accessible_resources_url() == "https://api.atlassian.com/oauth/token/accessible-resources"
assert jira.jira_rest_url("cid", "issue") == "https://api.atlassian.com/ex/jira/cid/rest/api/3/issue"
def test_jira_bases_are_overridable(reload_jira_urls):
jira = reload_jira_urls(
JIRA_AUTH_BASE="http://127.0.0.1:14020/jira-auth/",
JIRA_API_BASE="http://127.0.0.1:14020/jira-api",
)
assert jira.jira_token_url() == "http://127.0.0.1:14020/jira-auth/oauth/token"
assert jira.jira_accessible_resources_url() == (
"http://127.0.0.1:14020/jira-api/oauth/token/accessible-resources"
)
assert jira.jira_rest_url("cid", "project") == "http://127.0.0.1:14020/jira-api/ex/jira/cid/rest/api/3/project"
service = jira.JiraService()
assert service.token_url == "http://127.0.0.1:14020/jira-auth/oauth/token"
assert service.get_authorization_url("abc").startswith("http://127.0.0.1:14020/jira-auth/authorize?")
assert "state=abc" in service.get_authorization_url("abc")