198 lines
8.2 KiB
Python
198 lines
8.2 KiB
Python
"""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="<p>Hello <b>world</b></p>")
|
|
result = normalize_jira_issue(raw, project_id="p")
|
|
assert "<p>" not in result.content
|
|
assert "<b>" 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 |