feat: implement knowledge management system (spec complete, all 53 tasks done)
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user