update project knowledge function
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user