Files
cowork-local/tests/test_jira_knowledge_sync.py
T

278 lines
10 KiB
Python

"""Unit tests for Jira knowledge synchronization service.
Covers the mandatory production contract:
- Full sync with paginated fetch
- Incremental sync using cursor
- Idempotent reruns (duplicate issues overwrite cleanly)
- Tombstone / clear on full sync
- Partial failure tolerance (one malformed issue does not abort batch)
- Bounded batches
- Manifest state tracking
- Credential resolution per-call
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import patch
import pytest
from cowork_local.application.jira_knowledge.credential_resolver import JiraCredentialResolver
from cowork_local.application.jira_knowledge.index_repository import JiraKnowledgeIndex
from cowork_local.application.jira_knowledge.sync_service import JiraSyncService
from cowork_local.application.jira_knowledge.target_resolver import JiraTarget, JiraTargetResolver
from cowork_local.domain.jira_knowledge.sync_state import load_manifest
from cowork_local.mcp_servers.project_context.foundation import IdentityContext
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FakeTargetResolver:
target: JiraTarget
def resolve(self, identity: IdentityContext) -> JiraTarget:
return self.target
@dataclass(frozen=True)
class FakeCredentialResolver:
email: str = "test@example.com"
api_token: str = "fake-token"
def resolve(self, identity: IdentityContext):
from cowork_local.application.jira_knowledge.credential_resolver import JiraCredentials
return JiraCredentials(email=self.email, api_token=self.api_token)
def _make_issue(key: str, summary: str = "Test", updated: str = "2025-06-01T10:00:00.000+0000") -> dict:
return {
"key": key,
"fields": {
"summary": summary,
"description": f"Description for {key}",
"issuetype": {"name": "Story"},
"status": {"name": "Open"},
"labels": [],
"components": [],
"updated": updated,
"created": "2025-05-01T08:00:00.000+0000",
},
}
def _fake_search_response(issues: List[dict], total: int | None = None) -> dict:
return {
"issues": issues,
"total": total if total is not None else len(issues),
"startAt": 0,
"maxResults": 50,
}
@pytest.fixture
def index_root(tmp_path: Path) -> Path:
return tmp_path / "jira_kb"
@pytest.fixture
def identity() -> IdentityContext:
return IdentityContext(
actor_id="sync-agent",
org_unit="eng",
customer="internal",
project="proj-alpha",
granted_scopes=frozenset({"read"}),
)
@pytest.fixture
def target() -> JiraTarget:
return JiraTarget(
jira_project_key="ALPHA",
jira_base_url="https://jira.test",
cowork_project_id="proj-alpha",
)
@pytest.fixture
def service(index_root: Path, target: JiraTarget) -> JiraSyncService:
return JiraSyncService(
target_resolver=FakeTargetResolver(target),
credential_resolver=FakeCredentialResolver(),
index=JiraKnowledgeIndex(index_root=index_root),
index_root=index_root,
)
# ---------------------------------------------------------------------------
# Full sync
# ---------------------------------------------------------------------------
class TestFullSync:
@patch("cowork_local.core.jira_tool._get")
def test_full_sync_indexes_all_issues(self, mock_get, service, identity, index_root):
issues = [_make_issue(f"ALPHA-{i}") for i in range(3)]
mock_get.return_value = _fake_search_response(issues)
result = service.full_sync(identity)
assert result.processed == 3
assert result.failed == 0
assert result.total_indexed == 3
assert result.duration_seconds >= 0
# Verify files on disk
idx = JiraKnowledgeIndex(index_root=index_root)
assert idx.count("proj-alpha") == 3
@patch("cowork_local.core.jira_tool._get")
def test_full_sync_clears_previous_index(self, mock_get, service, identity, index_root):
# Pre-populate with an old issue
idx = JiraKnowledgeIndex(index_root=index_root)
from cowork_local.domain.jira_knowledge.canonical_issue import CanonicalJiraIssue, JiraProvenance
old = CanonicalJiraIssue(
knowledge_id="OLD/OLD-1", project_id="proj-alpha",
title="Old", content="old", provenance=JiraProvenance(issue_key="OLD-1"),
)
idx.upsert(old)
assert idx.count("proj-alpha") == 1
# Full sync with new issues
mock_get.return_value = _fake_search_response([_make_issue("ALPHA-99")])
service.full_sync(identity)
assert idx.count("proj-alpha") == 1
loaded = idx.load("proj-alpha", "ALPHA/ALPHA-99")
assert loaded is not None
assert idx.load("proj-alpha", "OLD/OLD-1") is None
@patch("cowork_local.core.jira_tool._get")
def test_full_sync_updates_manifest(self, mock_get, service, identity, index_root):
mock_get.return_value = _fake_search_response([_make_issue("ALPHA-1")])
service.full_sync(identity)
manifest = load_manifest(index_root, "proj-alpha")
assert manifest.last_successful_sync != ""
assert manifest.processed_count == 1
assert manifest.failed_count == 0
assert manifest.total_issues_indexed == 1
assert manifest.error_category == ""
# ---------------------------------------------------------------------------
# Incremental sync
# ---------------------------------------------------------------------------
class TestIncrementalSync:
@patch("cowork_local.core.jira_tool._get")
def test_incremental_falls_back_to_full_when_no_cursor(self, mock_get, service, identity):
mock_get.return_value = _fake_search_response([_make_issue("ALPHA-1")])
result = service.incremental_sync(identity)
assert result.processed == 1
# Should have used full-sync JQL (no AND updated clause)
call_args = mock_get.call_args
jql = call_args[0][2].get("jql", "") if len(call_args[0]) > 2 else call_args[1].get("params", {}).get("jql", "")
assert "AND updated >=" not in jql
@patch("cowork_local.core.jira_tool._get")
def test_incremental_uses_cursor_from_manifest(self, mock_get, service, identity, index_root):
# First full sync to establish cursor
mock_get.return_value = _fake_search_response(
[_make_issue("ALPHA-1", updated="2025-06-01T10:00:00.000+0000")]
)
service.full_sync(identity)
# Now incremental
mock_get.reset_mock()
mock_get.return_value = _fake_search_response(
[_make_issue("ALPHA-2", updated="2025-06-02T10:00:00.000+0000")]
)
service.incremental_sync(identity)
call_args = mock_get.call_args
params = call_args[0][2] if len(call_args[0]) > 2 else call_args[1].get("params", {})
jql = params.get("jql", "")
assert "AND updated >=" in jql
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
class TestIdempotency:
@patch("cowork_local.core.jira_tool._get")
def test_rerun_overwrites_same_issue(self, mock_get, service, identity, index_root):
issue_v1 = _make_issue("ALPHA-1", summary="Version 1")
mock_get.return_value = _fake_search_response([issue_v1])
service.full_sync(identity)
idx = JiraKnowledgeIndex(index_root=index_root)
loaded = idx.load("proj-alpha", "ALPHA/ALPHA-1")
assert loaded.title == "Version 1"
# Re-sync with updated summary
issue_v2 = _make_issue("ALPHA-1", summary="Version 2")
mock_get.return_value = _fake_search_response([issue_v2])
service.full_sync(identity)
loaded = idx.load("proj-alpha", "ALPHA/ALPHA-1")
assert loaded.title == "Version 2"
assert idx.count("proj-alpha") == 1 # no duplicate
# ---------------------------------------------------------------------------
# Partial failure tolerance
# ---------------------------------------------------------------------------
class TestPartialFailure:
@patch("cowork_local.core.jira_tool._get")
def test_malformed_issue_does_not_abort_batch(self, mock_get, service, identity, index_root):
good = _make_issue("ALPHA-1")
bad = {"key": "", "fields": {}} # missing key → normalize raises ValueError
good2 = _make_issue("ALPHA-2")
mock_get.return_value = _fake_search_response([good, bad, good2])
result = service.full_sync(identity)
assert result.processed == 2
assert result.failed == 1
idx = JiraKnowledgeIndex(index_root=index_root)
assert idx.count("proj-alpha") == 2
# ---------------------------------------------------------------------------
# Empty results
# ---------------------------------------------------------------------------
class TestEmptyResults:
@patch("cowork_local.core.jira_tool._get")
def test_empty_project_syncs_cleanly(self, mock_get, service, identity):
mock_get.return_value = _fake_search_response([], total=0)
result = service.full_sync(identity)
assert result.processed == 0
assert result.failed == 0
assert result.total_indexed == 0
# ---------------------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------------------
class TestPagination:
@patch("cowork_local.core.jira_tool._get")
def test_multi_page_fetch(self, mock_get, service, identity, index_root):
page1 = [_make_issue(f"ALPHA-{i}") for i in range(50)]
page2 = [_make_issue(f"ALPHA-{i}") for i in range(50, 75)]
mock_get.side_effect = [
_fake_search_response(page1, total=75),
_fake_search_response(page2, total=75),
]
result = service.full_sync(identity)
assert result.processed == 75
assert mock_get.call_count == 2
idx = JiraKnowledgeIndex(index_root=index_root)
assert idx.count("proj-alpha") == 75