278 lines
10 KiB
Python
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 |