451 lines
16 KiB
Python
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)
|