We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/sooperset/mcp-atlassian'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Tests for the Jira SLA mixin."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from mcp_atlassian.jira import JiraFetcher
from mcp_atlassian.jira.config import SLAConfig
from mcp_atlassian.jira.sla import SLAMixin
from mcp_atlassian.models.jira.metrics import (
IssueDatesResponse,
StatusChangeEntry,
StatusTimeSummary,
)
from mcp_atlassian.models.jira.sla import (
CycleTimeMetric,
DueDateComplianceMetric,
IssueSLABatchResponse,
IssueSLAMetrics,
IssueSLAResponse,
LeadTimeMetric,
TimeInStatusEntry,
WorkingHoursConfig,
)
class TestSLAConfig:
"""Tests for SLAConfig dataclass."""
def test_default_working_days(self):
"""Test default working days are Monday-Friday."""
config = SLAConfig(default_metrics=["cycle_time"])
assert config.working_days == [1, 2, 3, 4, 5]
def test_custom_working_days(self):
"""Test custom working days."""
config = SLAConfig(
default_metrics=["cycle_time"],
working_days=[1, 2, 3], # Mon-Wed
)
assert config.working_days == [1, 2, 3]
def test_invalid_working_days_low(self):
"""Test validation rejects day 0."""
with pytest.raises(ValueError) as exc_info:
SLAConfig(default_metrics=["cycle_time"], working_days=[0, 1, 2])
assert "Invalid working days" in str(exc_info.value)
assert "0" in str(exc_info.value)
def test_invalid_working_days_high(self):
"""Test validation rejects day 8."""
with pytest.raises(ValueError) as exc_info:
SLAConfig(default_metrics=["cycle_time"], working_days=[1, 2, 8])
assert "Invalid working days" in str(exc_info.value)
assert "8" in str(exc_info.value)
def test_invalid_working_days_multiple(self):
"""Test validation rejects multiple invalid days."""
with pytest.raises(ValueError) as exc_info:
SLAConfig(default_metrics=["cycle_time"], working_days=[0, 8, 9])
assert "Invalid working days" in str(exc_info.value)
def test_from_env_defaults(self):
"""Test from_env with defaults."""
with patch.dict("os.environ", {}, clear=True):
config = SLAConfig.from_env()
assert config.default_metrics == ["cycle_time", "time_in_status"]
assert config.working_hours_only is False
assert config.working_hours_start == "09:00"
assert config.working_hours_end == "17:00"
assert config.working_days == [1, 2, 3, 4, 5]
assert config.timezone == "UTC"
def test_from_env_custom_values(self):
"""Test from_env with custom environment variables."""
env = {
"JIRA_SLA_METRICS": "lead_time,resolution_time",
"JIRA_SLA_WORKING_HOURS_ONLY": "true",
"JIRA_SLA_WORKING_HOURS_START": "08:00",
"JIRA_SLA_WORKING_HOURS_END": "18:00",
"JIRA_SLA_WORKING_DAYS": "1,2,3,4",
"JIRA_SLA_TIMEZONE": "America/New_York",
}
with patch.dict("os.environ", env, clear=True):
config = SLAConfig.from_env()
assert config.default_metrics == ["lead_time", "resolution_time"]
assert config.working_hours_only is True
assert config.working_hours_start == "08:00"
assert config.working_hours_end == "18:00"
assert config.working_days == [1, 2, 3, 4]
assert config.timezone == "America/New_York"
def test_from_env_invalid_working_days(self):
"""Test from_env raises error for invalid working days."""
env = {"JIRA_SLA_WORKING_DAYS": "0,8,9"}
with patch.dict("os.environ", env, clear=True):
with pytest.raises(ValueError) as exc_info:
SLAConfig.from_env()
assert "Invalid JIRA_SLA_WORKING_DAYS" in str(exc_info.value)
class TestSLAMixin:
"""Tests for the SLAMixin class."""
@pytest.fixture
def sla_mixin(self, jira_fetcher: JiraFetcher) -> SLAMixin:
"""Create an SLAMixin instance with mocked dependencies."""
return jira_fetcher
@pytest.fixture
def mock_issue_dates(self) -> IssueDatesResponse:
"""Create mock issue dates response."""
return IssueDatesResponse(
issue_key="TEST-123",
created=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
updated=datetime(2023, 1, 15, 12, 0, tzinfo=timezone.utc),
due_date=datetime(2023, 2, 1, 23, 59, tzinfo=timezone.utc),
resolution_date=datetime(2023, 1, 20, 10, 0, tzinfo=timezone.utc),
current_status="Done",
status_changes=[
StatusChangeEntry(
status="Open",
entered_at=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
exited_at=datetime(2023, 1, 2, 10, 0, tzinfo=timezone.utc),
duration_minutes=1440,
),
StatusChangeEntry(
status="In Progress",
entered_at=datetime(2023, 1, 2, 10, 0, tzinfo=timezone.utc),
exited_at=datetime(2023, 1, 10, 10, 0, tzinfo=timezone.utc),
duration_minutes=11520,
),
StatusChangeEntry(
status="Done",
entered_at=datetime(2023, 1, 10, 10, 0, tzinfo=timezone.utc),
exited_at=None,
duration_minutes=None,
),
],
status_summary=[
StatusTimeSummary(
status="Open",
total_duration_minutes=1440,
total_duration_formatted="1d 0h 0m",
visit_count=1,
),
StatusTimeSummary(
status="In Progress",
total_duration_minutes=11520,
total_duration_formatted="8d 0h 0m",
visit_count=1,
),
],
)
def test_get_issue_sla_basic(
self, sla_mixin: SLAMixin, mock_issue_dates: IssueDatesResponse
):
"""Test basic SLA calculation."""
# Mock get_issue_dates to return our fixture
sla_mixin.get_issue_dates = MagicMock(return_value=mock_issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["cycle_time", "lead_time"],
working_hours_only=False,
)
assert isinstance(result, IssueSLAResponse)
assert result.issue_key == "TEST-123"
assert result.metrics.cycle_time is not None
assert result.metrics.lead_time is not None
def test_cycle_time_calculation(
self, sla_mixin: SLAMixin, mock_issue_dates: IssueDatesResponse
):
"""Test cycle time calculation (created to resolved)."""
sla_mixin.get_issue_dates = MagicMock(return_value=mock_issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["cycle_time"],
working_hours_only=False,
)
assert result.metrics.cycle_time is not None
assert result.metrics.cycle_time.calculated is True
# 19 days from Jan 1 to Jan 20 = 27360 minutes
assert result.metrics.cycle_time.value_minutes == 27360
def test_cycle_time_not_resolved(self, sla_mixin: SLAMixin):
"""Test cycle time when issue not resolved."""
issue_dates = IssueDatesResponse(
issue_key="TEST-123",
created=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
resolution_date=None, # Not resolved
current_status="In Progress",
)
sla_mixin.get_issue_dates = MagicMock(return_value=issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["cycle_time"],
working_hours_only=False,
)
assert result.metrics.cycle_time.calculated is False
assert "not resolved" in result.metrics.cycle_time.reason.lower()
def test_due_date_compliance_met(
self, sla_mixin: SLAMixin, mock_issue_dates: IssueDatesResponse
):
"""Test due date compliance when deadline met."""
sla_mixin.get_issue_dates = MagicMock(return_value=mock_issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["due_date_compliance"],
working_hours_only=False,
)
assert result.metrics.due_date_compliance is not None
assert result.metrics.due_date_compliance.status == "met"
assert "early" in result.metrics.due_date_compliance.formatted_margin
def test_due_date_compliance_missed(self, sla_mixin: SLAMixin):
"""Test due date compliance when deadline missed."""
issue_dates = IssueDatesResponse(
issue_key="TEST-123",
created=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
due_date=datetime(2023, 1, 15, 23, 59, tzinfo=timezone.utc),
resolution_date=datetime(2023, 1, 20, 10, 0, tzinfo=timezone.utc),
current_status="Done",
)
sla_mixin.get_issue_dates = MagicMock(return_value=issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["due_date_compliance"],
working_hours_only=False,
)
assert result.metrics.due_date_compliance.status == "missed"
assert "late" in result.metrics.due_date_compliance.formatted_margin
def test_time_in_status(
self, sla_mixin: SLAMixin, mock_issue_dates: IssueDatesResponse
):
"""Test time in status calculation."""
sla_mixin.get_issue_dates = MagicMock(return_value=mock_issue_dates)
result = sla_mixin.get_issue_sla(
issue_key="TEST-123",
metrics=["time_in_status"],
working_hours_only=False,
)
assert result.metrics.time_in_status is not None
assert len(result.metrics.time_in_status.statuses) >= 1
def test_batch_get_issue_sla(
self, sla_mixin: SLAMixin, mock_issue_dates: IssueDatesResponse
):
"""Test batch SLA calculation."""
sla_mixin.get_issue_dates = MagicMock(return_value=mock_issue_dates)
result = sla_mixin.batch_get_issue_sla(
issue_keys=["TEST-1", "TEST-2", "TEST-3"],
metrics=["cycle_time"],
working_hours_only=False,
)
assert isinstance(result, IssueSLABatchResponse)
assert result.total_count == 3
assert result.success_count == 3
assert result.error_count == 0
def test_batch_get_issue_sla_with_errors(self, sla_mixin: SLAMixin):
"""Test batch SLA calculation with some errors."""
def mock_get_dates(issue_key, **kwargs):
if issue_key == "TEST-2":
raise ValueError("Issue not found")
return IssueDatesResponse(
issue_key=issue_key,
created=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
resolution_date=datetime(2023, 1, 20, 10, 0, tzinfo=timezone.utc),
current_status="Done",
)
sla_mixin.get_issue_dates = MagicMock(side_effect=mock_get_dates)
result = sla_mixin.batch_get_issue_sla(
issue_keys=["TEST-1", "TEST-2", "TEST-3"],
metrics=["cycle_time"],
working_hours_only=False,
)
assert result.total_count == 3
assert result.success_count == 2
assert result.error_count == 1
assert result.errors[0]["issue_key"] == "TEST-2"
class TestSLATimezones:
"""Tests for SLA timezone handling."""
@pytest.fixture
def sla_mixin(self, jira_fetcher: JiraFetcher) -> SLAMixin:
"""Create an SLAMixin instance with mocked dependencies."""
return jira_fetcher
def test_timezone_utc(self, sla_mixin: SLAMixin):
"""Test SLA calculation with UTC timezone."""
sla_config = SLAConfig(
default_metrics=["lead_time"],
timezone="UTC",
)
tz = sla_mixin._get_sla_timezone(sla_config)
assert str(tz) == "UTC"
def test_timezone_new_york(self, sla_mixin: SLAMixin):
"""Test SLA calculation with America/New_York timezone."""
sla_config = SLAConfig(
default_metrics=["lead_time"],
timezone="America/New_York",
)
tz = sla_mixin._get_sla_timezone(sla_config)
assert str(tz) == "America/New_York"
def test_timezone_asia_seoul(self, sla_mixin: SLAMixin):
"""Test SLA calculation with Asia/Seoul timezone."""
sla_config = SLAConfig(
default_metrics=["lead_time"],
timezone="Asia/Seoul",
)
tz = sla_mixin._get_sla_timezone(sla_config)
assert str(tz) == "Asia/Seoul"
def test_timezone_invalid_fallback(self, sla_mixin: SLAMixin):
"""Test invalid timezone falls back to UTC."""
sla_config = SLAConfig(
default_metrics=["lead_time"],
timezone="Invalid/Timezone",
)
tz = sla_mixin._get_sla_timezone(sla_config)
assert str(tz) == "UTC"
class TestSLAWorkingHours:
"""Tests for SLA working hours calculation."""
@pytest.fixture
def sla_mixin(self, jira_fetcher: JiraFetcher) -> SLAMixin:
"""Create an SLAMixin instance with mocked dependencies."""
return jira_fetcher
def test_working_minutes_weekday(self, sla_mixin: SLAMixin):
"""Test working minutes on a single weekday."""
sla_config = SLAConfig(
default_metrics=["cycle_time"],
working_hours_start="09:00",
working_hours_end="17:00",
working_days=[1, 2, 3, 4, 5],
timezone="UTC",
)
# Monday 9am to 5pm = 8 hours = 480 minutes
start = datetime(2023, 1, 2, 9, 0, tzinfo=timezone.utc) # Monday
end = datetime(2023, 1, 2, 17, 0, tzinfo=timezone.utc)
result = sla_mixin._calculate_working_minutes(start, end, sla_config)
assert result == 480
def test_working_minutes_excludes_weekend(self, sla_mixin: SLAMixin):
"""Test working minutes excludes weekend days."""
sla_config = SLAConfig(
default_metrics=["cycle_time"],
working_hours_start="09:00",
working_hours_end="17:00",
working_days=[1, 2, 3, 4, 5],
timezone="UTC",
)
# Friday 9am to Monday 5pm should only count Friday and Monday
start = datetime(2023, 1, 6, 9, 0, tzinfo=timezone.utc) # Friday
end = datetime(2023, 1, 9, 17, 0, tzinfo=timezone.utc) # Monday
result = sla_mixin._calculate_working_minutes(start, end, sla_config)
# 8 hours Friday + 8 hours Monday = 960 minutes
assert result == 960
def test_working_minutes_partial_day(self, sla_mixin: SLAMixin):
"""Test working minutes for partial day."""
sla_config = SLAConfig(
default_metrics=["cycle_time"],
working_hours_start="09:00",
working_hours_end="17:00",
working_days=[1, 2, 3, 4, 5],
timezone="UTC",
)
# Monday 10am to 2pm = 4 hours = 240 minutes
start = datetime(2023, 1, 2, 10, 0, tzinfo=timezone.utc) # Monday
end = datetime(2023, 1, 2, 14, 0, tzinfo=timezone.utc)
result = sla_mixin._calculate_working_minutes(start, end, sla_config)
assert result == 240
def test_working_minutes_outside_hours(self, sla_mixin: SLAMixin):
"""Test working minutes when entirely outside working hours."""
sla_config = SLAConfig(
default_metrics=["cycle_time"],
working_hours_start="09:00",
working_hours_end="17:00",
working_days=[1, 2, 3, 4, 5],
timezone="UTC",
)
# Monday 6pm to 8pm = 0 minutes (after working hours)
start = datetime(2023, 1, 2, 18, 0, tzinfo=timezone.utc) # Monday
end = datetime(2023, 1, 2, 20, 0, tzinfo=timezone.utc)
result = sla_mixin._calculate_working_minutes(start, end, sla_config)
assert result == 0
class TestSLAModels:
"""Tests for SLA Pydantic models."""
def test_cycle_time_metric_calculated(self):
"""Test CycleTimeMetric serialization when calculated."""
metric = CycleTimeMetric(
value_minutes=1440,
formatted="1d 0h 0m",
calculated=True,
)
result = metric.to_simplified_dict()
assert result["calculated"] is True
assert result["value_minutes"] == 1440
assert result["formatted"] == "1d 0h 0m"
def test_cycle_time_metric_not_calculated(self):
"""Test CycleTimeMetric serialization when not calculated."""
metric = CycleTimeMetric(
calculated=False,
reason="Issue not resolved",
)
result = metric.to_simplified_dict()
assert result["calculated"] is False
assert result["reason"] == "Issue not resolved"
assert "value_minutes" not in result
def test_lead_time_metric(self):
"""Test LeadTimeMetric serialization."""
metric = LeadTimeMetric(
value_minutes=2880,
formatted="2d 0h 0m",
is_resolved=True,
)
result = metric.to_simplified_dict()
assert result["value_minutes"] == 2880
assert result["formatted"] == "2d 0h 0m"
assert result["is_resolved"] is True
def test_time_in_status_entry(self):
"""Test TimeInStatusEntry serialization."""
entry = TimeInStatusEntry(
status="In Progress",
value_minutes=1440,
formatted="1d 0h 0m",
percentage=50.0,
visit_count=2,
)
result = entry.to_simplified_dict()
assert result["status"] == "In Progress"
assert result["value_minutes"] == 1440
assert result["percentage"] == 50.0
assert result["visit_count"] == 2
def test_due_date_compliance_met(self):
"""Test DueDateComplianceMetric serialization when met."""
metric = DueDateComplianceMetric(
status="met",
margin_minutes=1440,
formatted_margin="1d 0h 0m early",
)
result = metric.to_simplified_dict()
assert result["status"] == "met"
assert result["margin_minutes"] == 1440
assert result["formatted_margin"] == "1d 0h 0m early"
def test_due_date_compliance_no_due_date(self):
"""Test DueDateComplianceMetric when no due date."""
metric = DueDateComplianceMetric(status="no_due_date")
result = metric.to_simplified_dict()
assert result["status"] == "no_due_date"
assert "margin_minutes" not in result
def test_issue_sla_response(self):
"""Test IssueSLAResponse serialization."""
response = IssueSLAResponse(
issue_key="TEST-123",
metrics=IssueSLAMetrics(
cycle_time=CycleTimeMetric(
value_minutes=1440,
formatted="1d 0h 0m",
calculated=True,
)
),
)
result = response.to_simplified_dict()
assert result["issue_key"] == "TEST-123"
assert "metrics" in result
assert result["metrics"]["cycle_time"]["calculated"] is True
def test_issue_sla_batch_response(self):
"""Test IssueSLABatchResponse serialization."""
response = IssueSLABatchResponse(
issues=[
IssueSLAResponse(
issue_key="TEST-1",
metrics=IssueSLAMetrics(),
)
],
total_count=2,
success_count=1,
error_count=1,
errors=[{"issue_key": "TEST-2", "error": "Not found"}],
metrics_calculated=["cycle_time"],
working_hours_applied=True,
working_hours_config=WorkingHoursConfig(
start="09:00",
end="17:00",
days=[1, 2, 3, 4, 5],
timezone="UTC",
),
)
result = response.to_simplified_dict()
assert result["total_count"] == 2
assert result["success_count"] == 1
assert result["error_count"] == 1
assert len(result["issues"]) == 1
assert len(result["errors"]) == 1
assert result["working_hours_applied"] is True
assert result["working_hours_config"]["start"] == "09:00"
class TestStatusCategoryCaching:
"""Tests for status category caching in SLA calculations."""
@pytest.fixture
def sla_mixin(self, jira_fetcher: JiraFetcher) -> SLAMixin:
"""Create an SLAMixin instance with mocked dependencies."""
return jira_fetcher
@pytest.fixture
def mock_statuses(self) -> list[dict]:
"""Create mock status list from Jira API."""
return [
{
"name": "Open",
"statusCategory": {"key": "new"},
},
{
"name": "In Progress",
"statusCategory": {"key": "indeterminate"},
},
{
"name": "In Development",
"statusCategory": {"key": "indeterminate"},
},
{
"name": "Done",
"statusCategory": {"key": "done"},
},
]
def test_status_category_cache_called_once(
self, sla_mixin: SLAMixin, mock_statuses: list[dict]
):
"""Test that get_all_statuses is only called once per instance."""
sla_mixin.jira.get_all_statuses = MagicMock(return_value=mock_statuses)
# Call multiple times
sla_mixin._get_status_category_map()
sla_mixin._get_status_category_map()
sla_mixin._get_status_category_map()
# Should only call API once
assert sla_mixin.jira.get_all_statuses.call_count == 1
def test_is_in_progress_uses_cache(
self, sla_mixin: SLAMixin, mock_statuses: list[dict]
):
"""Test that _is_in_progress_status uses cached data."""
sla_mixin.jira.get_all_statuses = MagicMock(return_value=mock_statuses)
# Check multiple statuses
assert sla_mixin._is_in_progress_status("TEST-1", "In Progress") is True
assert sla_mixin._is_in_progress_status("TEST-1", "In Development") is True
assert sla_mixin._is_in_progress_status("TEST-1", "Open") is False
assert sla_mixin._is_in_progress_status("TEST-1", "Done") is False
# Should only call API once despite multiple status checks
assert sla_mixin.jira.get_all_statuses.call_count == 1
def test_is_in_progress_case_insensitive(
self, sla_mixin: SLAMixin, mock_statuses: list[dict]
):
"""Test that status lookup is case-insensitive."""
sla_mixin.jira.get_all_statuses = MagicMock(return_value=mock_statuses)
assert sla_mixin._is_in_progress_status("TEST-1", "in progress") is True
assert sla_mixin._is_in_progress_status("TEST-1", "IN PROGRESS") is True
assert sla_mixin._is_in_progress_status("TEST-1", "In Progress") is True
def test_fallback_when_api_fails(self, sla_mixin: SLAMixin):
"""Test fallback to name-based check when API fails."""
sla_mixin.jira.get_all_statuses = MagicMock(side_effect=Exception("API error"))
# Should fallback to name-based check
assert sla_mixin._is_in_progress_status("TEST-1", "in progress") is True
assert sla_mixin._is_in_progress_status("TEST-1", "in development") is True
assert sla_mixin._is_in_progress_status("TEST-1", "working") is True
assert sla_mixin._is_in_progress_status("TEST-1", "open") is False
def test_fallback_for_unknown_status(
self, sla_mixin: SLAMixin, mock_statuses: list[dict]
):
"""Test fallback when status not in cache."""
sla_mixin.jira.get_all_statuses = MagicMock(return_value=mock_statuses)
# Unknown status not in cache falls back to name-based check
assert sla_mixin._is_in_progress_status("TEST-1", "in progress") is True
# "Custom Status" not in mock_statuses, falls back to name check
assert sla_mixin._is_in_progress_status("TEST-1", "Custom Status") is False
def test_cache_persists_across_resolution_time_calls(
self, sla_mixin: SLAMixin, mock_statuses: list[dict]
):
"""Test cache persists when calculating resolution time for multiple issues."""
sla_mixin.jira.get_all_statuses = MagicMock(return_value=mock_statuses)
# Create mock issue dates with status changes
issue_dates = IssueDatesResponse(
issue_key="TEST-123",
created=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
resolution_date=datetime(2023, 1, 20, 10, 0, tzinfo=timezone.utc),
current_status="Done",
status_changes=[
StatusChangeEntry(
status="Open",
entered_at=datetime(2023, 1, 1, 10, 0, tzinfo=timezone.utc),
exited_at=datetime(2023, 1, 2, 10, 0, tzinfo=timezone.utc),
duration_minutes=1440,
),
StatusChangeEntry(
status="In Progress",
entered_at=datetime(2023, 1, 2, 10, 0, tzinfo=timezone.utc),
exited_at=datetime(2023, 1, 10, 10, 0, tzinfo=timezone.utc),
duration_minutes=11520,
),
StatusChangeEntry(
status="Done",
entered_at=datetime(2023, 1, 10, 10, 0, tzinfo=timezone.utc),
exited_at=None,
duration_minutes=None,
),
],
)
sla_mixin.get_issue_dates = MagicMock(return_value=issue_dates)
# Calculate SLA for multiple issues
sla_mixin.get_issue_sla("TEST-1", metrics=["resolution_time"])
sla_mixin.get_issue_sla("TEST-2", metrics=["resolution_time"])
sla_mixin.get_issue_sla("TEST-3", metrics=["resolution_time"])
# API should only be called once across all issues
assert sla_mixin.jira.get_all_statuses.call_count == 1