"""Unit tests for Jira issue normalization into canonical knowledge documents. Covers the mandatory production contract: - Story/Requirement, Bug, Task normalization - Empty description handling - Long content bounding - Jira markup stripping - Missing/malformed custom fields - Provenance completeness - Stable knowledge identity """ from __future__ import annotations import pytest from cowork_local.domain.jira_knowledge.canonical_issue import ( CanonicalJiraIssue, JiraProvenance, normalize_jira_issue, ) def _raw_issue( key: str = "PROJ-101", summary: str = "Test issue", description: str | None = "A test description.", issue_type: str = "Story", status: str = "Open", labels: list[str] | None = None, components: list[str] | None = None, updated: str = "2025-06-01T10:00:00.000+0000", created: str = "2025-05-01T08:00:00.000+0000", extra_fields: dict | None = None, ) -> dict: """Build a minimal raw Jira issue dict for testing.""" fields: dict = { "summary": summary, "description": description, "issuetype": {"name": issue_type}, "status": {"name": status}, "labels": labels or [], "components": [{"name": c} for c in (components or [])], "updated": updated, "created": created, } if extra_fields: fields.update(extra_fields) return {"key": key, "fields": fields} # --------------------------------------------------------------------------- # Happy-path normalization # --------------------------------------------------------------------------- class TestHappyPath: def test_story_normalization(self) -> None: raw = _raw_issue( key="ALPHA-42", summary="User login flow", description="As a user I want to log in with email and password.", issue_type="Story", status="In Progress", labels=["auth", "login"], components=["Backend"], ) result = normalize_jira_issue(raw, project_id="proj-alpha", jira_base_url="https://jira.example.com") assert isinstance(result, CanonicalJiraIssue) assert result.knowledge_id == "ALPHA/ALPHA-42" assert result.project_id == "proj-alpha" assert result.title == "User login flow" assert "log in with email" in result.content assert result.metadata["issue_type"] == "Story" assert result.metadata["status"] == "In Progress" assert result.metadata["labels"] == ["auth", "login"] assert result.metadata["components"] == ["Backend"] def test_bug_normalization(self) -> None: raw = _raw_issue(key="BUG-7", summary="Crash on startup", issue_type="Bug", status="Closed") result = normalize_jira_issue(raw, project_id="proj-beta", jira_base_url="https://jira.example.com") assert result.provenance.issue_type == "Bug" assert result.provenance.status == "Closed" assert result.provenance.issue_key == "BUG-7" def test_task_normalization(self) -> None: raw = _raw_issue(key="TASK-3", summary="Update dependencies", issue_type="Task") result = normalize_jira_issue(raw, project_id="proj-gamma") assert result.provenance.issue_type == "Task" assert result.title == "Update dependencies" def test_provenance_completeness(self) -> None: raw = _raw_issue(key="XY-99", updated="2025-07-15T12:00:00.000+0000") result = normalize_jira_issue(raw, project_id="p", jira_base_url="https://j.test") prov = result.provenance assert prov.system == "jira" assert prov.issue_key == "XY-99" assert prov.project_key == "XY" assert prov.source_url == "https://j.test/browse/XY-99" assert prov.source_updated == "2025-07-15T12:00:00.000+0000" assert result.ingested_at # non-empty ISO timestamp def test_stable_knowledge_identity(self) -> None: """Same raw input always produces the same knowledge_id.""" raw = _raw_issue(key="STABLE-1") a = normalize_jira_issue(raw, project_id="p") b = normalize_jira_issue(raw, project_id="p") assert a.knowledge_id == b.knowledge_id == "STABLE/STABLE-1" # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- class TestEdgeCases: def test_empty_description(self) -> None: raw = _raw_issue(description=None) result = normalize_jira_issue(raw, project_id="p") assert result.content == "" or result.content.strip() == "" def test_empty_string_description(self) -> None: raw = _raw_issue(description="") result = normalize_jira_issue(raw, project_id="p") # Should not crash; content may include labels/components but no desc block. assert isinstance(result, CanonicalJiraIssue) def test_long_content_bounded(self) -> None: long_desc = "x" * 100_000 raw = _raw_issue(description=long_desc) result = normalize_jira_issue(raw, project_id="p") assert len(result.content) <= 200_000 def test_jira_markup_link_stripped(self) -> None: raw = _raw_issue(description="See [documentation|https://docs.example.com/page] for details.") result = normalize_jira_issue(raw, project_id="p") assert "documentation" in result.content assert "[documentation|" not in result.content assert "https://docs.example.com/page" not in result.content def test_html_tags_stripped(self) -> None: raw = _raw_issue(description="

Hello world

") result = normalize_jira_issue(raw, project_id="p") assert "

" not in result.content assert "" not in result.content assert "Hello" in result.content assert "world" in result.content def test_missing_custom_fields(self) -> None: """Missing optional fields do not cause errors.""" raw = _raw_issue() del raw["fields"]["labels"] del raw["fields"]["components"] result = normalize_jira_issue(raw, project_id="p") assert result.metadata["labels"] == [] assert result.metadata["components"] == [] def test_malformed_issuetype_not_dict(self) -> None: raw = _raw_issue() raw["fields"]["issuetype"] = "Story" # wrong shape result = normalize_jira_issue(raw, project_id="p") assert result.provenance.issue_type == "" def test_adf_rich_text_description_placeholder(self) -> None: raw = _raw_issue(description={"type": "doc", "version": 1, "content": []}) result = normalize_jira_issue(raw, project_id="p") assert "rich-text" in result.content.lower() or "open in Jira" in result.content def test_acceptance_criteria_from_heading(self) -> None: desc = "# Acceptance Criteria\n- User can log in\n- Session expires after 30 min\n## Notes\nSome notes." raw = _raw_issue(description=desc) result = normalize_jira_issue(raw, project_id="p") assert result.metadata.get("has_acceptance_criteria") is True assert "User can log in" in result.content def test_linked_issues_bounded(self) -> None: links = [{"outwardIssue": {"key": f"LINK-{i}"}} for i in range(30)] raw = _raw_issue(extra_fields={"issuelinks": links}) result = normalize_jira_issue(raw, project_id="p") assert len(result.metadata["linked_issues"]) <= 10 # --------------------------------------------------------------------------- # Error cases # --------------------------------------------------------------------------- class TestErrors: def test_missing_key_raises(self) -> None: with pytest.raises(ValueError, match="missing 'key'"): normalize_jira_issue({"fields": {}}, project_id="p") def test_non_dict_raw_raises(self) -> None: with pytest.raises(ValueError, match="must be a dict"): normalize_jira_issue("not a dict", project_id="p") # type: ignore[arg-type] def test_missing_fields_treated_as_empty(self) -> None: """A raw dict with key but no fields block should not crash.""" result = normalize_jira_issue({"key": "X-1"}, project_id="p") assert result.knowledge_id == "X/X-1" assert result.title == "X-1" # falls back to key when no summary