"""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)