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