Files
Orchestrator/shared/tools/monorepo-cli/tests/test_jira_source.py
T

451 lines
16 KiB
Python

"""Unit-Tests für die Jira-Quellstrategie.
Testet JQL-basierte Issue-Extraktion, Markdown-Komposition,
Frontmatter-Anreicherung und inkrementelle Updates mit gemockten HTTP-Responses.
Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.7
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from monorepo.knowledge.sources.base import SourceConfig
from monorepo.knowledge.sources.jira import (
JiraSource,
_build_frontmatter,
_build_issue_markdown,
_parse_jira_date,
_slugify,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_issue(
key: str = "ACV2-123",
summary: str = "Fix login bug",
description: str = "Users cannot log in via SSO.",
status: str = "In Progress",
project: str = "ACV2",
assignee: str = "Max Mustermann",
reporter: str = "Anna Schmidt",
updated: str = "2025-07-01T10:30:00.000+0200",
created: str = "2025-06-15T08:00:00.000+0200",
comments: list | None = None,
) -> dict:
"""Erstellt ein Jira-Issue-Dict für Tests."""
if comments is None:
comments = []
return {
"key": key,
"fields": {
"summary": summary,
"description": description,
"status": {"name": status},
"project": {"key": project},
"assignee": {"displayName": assignee} if assignee else None,
"reporter": {"displayName": reporter} if reporter else None,
"updated": updated,
"created": created,
"comment": {"comments": comments},
},
}
def _make_comment(
author: str = "Max Mustermann",
body: str = "Fixed in latest commit.",
created: str = "2025-07-01T12:00:00.000+0200",
) -> dict:
return {
"author": {"displayName": author},
"body": body,
"created": created,
}
def _make_config(
base_url: str = "https://jira.bahn.de",
token: str = "test-token",
jql: str = "project = ACV2 AND updatedDate > -7d",
username: str = "",
) -> SourceConfig:
"""Erstellt eine SourceConfig für Jira-Tests."""
params = {"base_url": base_url, "jql": jql, "api_key": token}
if username:
params["username"] = username
return SourceConfig(type="jira", name="Test Jira", params=params)
def _mock_response(status_code: int = 200, json_data: dict | None = None):
"""Erstellt ein Mock-Response-Objekt."""
mock = MagicMock()
mock.status_code = status_code
mock.json.return_value = json_data or {}
mock.text = "error" if status_code >= 400 else ""
return mock
# ---------------------------------------------------------------------------
# Tests: Hilfsfunktionen
# ---------------------------------------------------------------------------
class TestParseJiraDate:
"""Tests für _parse_jira_date."""
def test_iso_datetime(self):
result = _parse_jira_date("2025-07-01T10:30:00.000+0200")
assert result is not None
assert result.year == 2025
assert result.month == 7
assert result.day == 1
def test_iso_date_only(self):
result = _parse_jira_date("2025-07-01")
assert result is not None
assert result.year == 2025
def test_none_input(self):
assert _parse_jira_date(None) is None
def test_empty_string(self):
assert _parse_jira_date("") is None
def test_invalid_string(self):
assert _parse_jira_date("not-a-date") is None
class TestSlugify:
"""Tests für _slugify."""
def test_simple(self):
assert _slugify("In Progress") == "in-progress"
def test_special_chars(self):
assert _slugify("ACV2/Sprint-1!") == "acv2-sprint-1"
def test_empty(self):
assert _slugify("") == ""
class TestBuildIssueMarkdown:
"""Tests für _build_issue_markdown."""
def test_basic_issue(self):
issue = _make_issue()
md = _build_issue_markdown(issue)
assert "# Fix login bug" in md
assert "**Status:** In Progress" in md
assert "## Description" in md
assert "Users cannot log in via SSO." in md
def test_issue_without_description(self):
issue = _make_issue(description=None)
md = _build_issue_markdown(issue)
assert "# Fix login bug" in md
assert "## Description" not in md
def test_issue_with_comments(self):
comments = [
_make_comment(author="Max", body="Working on it.", created="2025-07-01T12:00:00.000+0200"),
_make_comment(author="Anna", body="Please prioritize.", created="2025-07-02T09:00:00.000+0200"),
]
issue = _make_issue(comments=comments)
md = _build_issue_markdown(issue)
assert "## Comments" in md
assert "### Max (2025-07-01)" in md
assert "Working on it." in md
assert "### Anna (2025-07-02)" in md
assert "Please prioritize." in md
def test_issue_without_comments(self):
issue = _make_issue(comments=[])
md = _build_issue_markdown(issue)
assert "## Comments" not in md
class TestBuildFrontmatter:
"""Tests für _build_frontmatter."""
def test_full_issue(self):
issue = _make_issue()
fm = _build_frontmatter(issue, "https://jira.bahn.de")
assert fm["type"] == "jira"
assert fm["issue_key"] == "ACV2-123"
assert fm["project"] == "ACV2"
assert fm["status"] == "In Progress"
assert fm["assignee"] == "Max Mustermann"
assert fm["reporter"] == "Anna Schmidt"
assert fm["url"] == "https://jira.bahn.de/browse/ACV2-123"
def test_no_assignee(self):
issue = _make_issue(assignee=None)
fm = _build_frontmatter(issue, "https://jira.bahn.de")
assert fm["assignee"] == ""
def test_trailing_slash_in_url(self):
issue = _make_issue(key="PROJ-1")
fm = _build_frontmatter(issue, "https://jira.bahn.de/")
assert fm["url"] == "https://jira.bahn.de/browse/PROJ-1"
# ---------------------------------------------------------------------------
# Tests: JiraSource.extract()
# ---------------------------------------------------------------------------
class TestJiraSourceExtract:
"""Tests für JiraSource.extract() mit gemockten HTTP-Responses."""
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_successful_extraction(self, mock_session_cls):
"""Erfolgreiche Extraktion von Issues."""
issues = [_make_issue(), _make_issue(key="ACV2-456", summary="Add feature")]
response = _mock_response(200, {"issues": issues, "total": 2})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 2
assert result.errors == []
assert result.skipped == 0
# Erstes Artefakt prüfen
art = result.artifacts[0]
assert art.metadata.type == "jira"
assert art.metadata.title == "Fix login bug"
assert art.metadata.source_context == "bahn"
assert art.metadata.source["issue_key"] == "ACV2-123"
assert art.metadata.source["project"] == "ACV2"
assert art.metadata.source["status"] == "In Progress"
assert art.metadata.source["url"] == "https://jira.bahn.de/browse/ACV2-123"
assert art.metadata.category == "project"
assert "acv2" in art.metadata.tags
assert art.metadata.content_hash.startswith("sha256:")
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_incremental_skip(self, mock_session_cls):
"""Bereits bekannte Issues werden übersprungen."""
issue = _make_issue(updated="2025-07-01T10:30:00.000+0200")
response = _mock_response(200, {"issues": [issue], "total": 1})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
source.set_last_updated({"ACV2-123": "2025-07-01T10:30:00.000+0200"})
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 0
assert result.skipped == 1
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_incremental_update_when_changed(self, mock_session_cls):
"""Geänderte Issues werden erneut verarbeitet."""
issue = _make_issue(updated="2025-07-02T10:30:00.000+0200")
response = _mock_response(200, {"issues": [issue], "total": 1})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
source.set_last_updated({"ACV2-123": "2025-07-01T10:30:00.000+0200"})
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 1
assert result.skipped == 0
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_auth_failure(self, mock_session_cls):
"""401-Fehler wird als Auth-Error gemeldet."""
response = _mock_response(401)
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 0
assert len(result.errors) == 1
assert result.errors[0].error_type == "auth"
assert result.errors[0].retry is False
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_connection_error(self, mock_session_cls):
"""Verbindungsfehler wird graceful gehandelt."""
import requests as req
session_instance = MagicMock()
session_instance.get.side_effect = req.ConnectionError("refused")
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 0
assert len(result.errors) == 1
assert result.errors[0].error_type == "connection"
assert result.errors[0].retry is True
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_server_error_retryable(self, mock_session_cls):
"""5xx-Fehler sind retry-fähig."""
response = _mock_response(500)
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert result.errors[0].retry is True
def test_missing_base_url(self):
"""Fehlende base_url wird als Config-Error gemeldet."""
config = SourceConfig(
type="jira",
name="Bad Config",
params={"jql": "project = X"},
)
source = JiraSource()
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert result.errors[0].error_type == "config"
assert "base_url" in result.errors[0].message
def test_missing_jql(self):
"""Fehlende JQL wird als Config-Error gemeldet."""
config = SourceConfig(
type="jira",
name="Bad Config",
params={"base_url": "https://jira.test.de"},
)
source = JiraSource()
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert result.errors[0].error_type == "config"
assert "jql" in result.errors[0].message
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_pagination(self, mock_session_cls):
"""Pagination über mehrere Seiten funktioniert."""
page1_issues = [_make_issue(key=f"ACV2-{i}") for i in range(3)]
page2_issues = [_make_issue(key=f"ACV2-{i}") for i in range(3, 5)]
resp1 = _mock_response(200, {"issues": page1_issues, "total": 5})
resp2 = _mock_response(200, {"issues": page2_issues, "total": 5})
session_instance = MagicMock()
session_instance.get.side_effect = [resp1, resp2]
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 5
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_issue_with_comments_in_artifact(self, mock_session_cls):
"""Kommentare werden im Markdown-Body dargestellt."""
comments = [_make_comment(author="Dev", body="Done.")]
issue = _make_issue(comments=comments)
response = _mock_response(200, {"issues": [issue], "total": 1})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert len(result.artifacts) == 1
assert "## Comments" in result.artifacts[0].content
assert "Done." in result.artifacts[0].content
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_artifact_file_path_from_key(self, mock_session_cls):
"""Dateiname wird aus Issue-Key abgeleitet."""
issue = _make_issue(key="PROJ-99")
response = _mock_response(200, {"issues": [issue], "total": 1})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config()
result = source.extract(config, "bahn")
assert result.artifacts[0].file_path.name == "proj-99.md"
class TestJiraSourceSupportsIncremental:
"""Tests für supports_incremental()."""
def test_returns_true(self):
source = JiraSource()
assert source.supports_incremental() is True
class TestJiraSourceAuth:
"""Tests für Authentifizierungs-Konfiguration."""
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_basic_auth_with_username(self, mock_session_cls):
"""Bei username + token wird Basic Auth verwendet."""
response = _mock_response(200, {"issues": [], "total": 0})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config(username="user@bahn.de", token="secret")
source.extract(config, "bahn")
# Verify Basic Auth was set
assert session_instance.auth == ("user@bahn.de", "secret")
@patch("monorepo.knowledge.sources.jira.requests.Session")
def test_bearer_token_without_username(self, mock_session_cls):
"""Ohne username wird Bearer-Token verwendet."""
response = _mock_response(200, {"issues": [], "total": 0})
session_instance = MagicMock()
session_instance.get.return_value = response
mock_session_cls.return_value = session_instance
source = JiraSource()
config = _make_config(token="bearer-token")
source.extract(config, "bahn")
# Verify Bearer was set in headers
assert session_instance.headers.__setitem__.call_args_list[2] == (
("Authorization", "Bearer bearer-token"),
) or "Bearer" in str(session_instance.headers)