feat: transform to agent coordination platform with comprehensive documentation

Transform Issue Facade from a universal CLI tool into an agent coordination platform with comprehensive documentation and enhanced capabilities for autonomous coding agents.

Major Changes:
- Complete README rewrite focusing on agent-driven coordination
- New comprehensive documentation (AGENT_INTEGRATION.md, CLAUDE.md, ROADMAP.md)
- Capability integration setup with CAPABILITY.yaml and integration scripts
- Enhanced Makefile with local development targets for easier workflows

Bug Fixes:
- Fix schema initialization using executescript() for multi-line SQL support
- Disable FTS5 triggers due to compatibility issues (documented for future re-enablement)

Features:
- Enhanced CLI list command with full parameter passthrough
- New examples directory with agent integration patterns
- New comprehensive test suite (test_core_models.py, test_local_backend.py)

Code Quality:
- Remove @cached_property decorators for Label properties (simplification)
- Clean up test organization (removed old test_gitea_integration.py)

This milestone establishes Issue Facade as a production-ready coordination layer for multi-agent software development, with clear integration paths and comprehensive developer documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 19:32:37 +01:00
parent 2dfe5130a3
commit 324453bd8d
22 changed files with 6489 additions and 835 deletions

602
tests/test_core_models.py Normal file
View File

@@ -0,0 +1,602 @@
"""
Test suite for Core Domain Models.
These tests ensure the domain models (Issue, Label, User, etc.) work correctly,
including state management, validation, and business logic.
"""
import pytest
from datetime import datetime, timezone
from issue_tracker.core.models import (
Issue, Label, User, Milestone, Comment,
IssueState, Priority, IssueType, LabelCategories
)
@pytest.mark.unit
class TestIssueState:
"""Test IssueState enumeration."""
def test_from_string_standard_states(self):
"""Test converting standard strings to IssueState."""
assert IssueState.from_string("open") == IssueState.OPEN
assert IssueState.from_string("closed") == IssueState.CLOSED
assert IssueState.from_string("in_progress") == IssueState.IN_PROGRESS
assert IssueState.from_string("blocked") == IssueState.BLOCKED
def test_from_string_variants(self):
"""Test converting variant strings to IssueState."""
assert IssueState.from_string("in-progress") == IssueState.IN_PROGRESS
assert IssueState.from_string("progress") == IssueState.IN_PROGRESS
assert IssueState.from_string("OPEN") == IssueState.OPEN
def test_from_string_unknown_defaults_to_open(self):
"""Test unknown state strings default to OPEN."""
assert IssueState.from_string("unknown") == IssueState.OPEN
assert IssueState.from_string("") == IssueState.OPEN
def test_to_backend_string_gitea(self):
"""Test converting to Gitea backend string."""
assert IssueState.OPEN.to_backend_string("gitea") == "open"
assert IssueState.CLOSED.to_backend_string("gitea") == "closed"
assert IssueState.IN_PROGRESS.to_backend_string("gitea") == "open"
assert IssueState.BLOCKED.to_backend_string("gitea") == "open"
def test_to_backend_string_github(self):
"""Test converting to GitHub backend string."""
assert IssueState.OPEN.to_backend_string("github") == "open"
assert IssueState.CLOSED.to_backend_string("github") == "closed"
@pytest.mark.unit
class TestPriority:
"""Test Priority enumeration."""
def test_from_label_with_priority_prefix(self):
"""Test extracting priority from label."""
assert Priority.from_label("priority:low") == Priority.LOW
assert Priority.from_label("priority:medium") == Priority.MEDIUM
assert Priority.from_label("priority:high") == Priority.HIGH
assert Priority.from_label("priority:critical") == Priority.CRITICAL
def test_from_label_without_prefix(self):
"""Test non-priority labels return None."""
assert Priority.from_label("bug") is None
assert Priority.from_label("enhancement") is None
def test_from_label_invalid_priority(self):
"""Test invalid priority returns None."""
assert Priority.from_label("priority:invalid") is None
@pytest.mark.unit
class TestIssueType:
"""Test IssueType enumeration."""
def test_from_label_standard_types(self):
"""Test extracting issue type from label."""
assert IssueType.from_label("bug") == IssueType.BUG
assert IssueType.from_label("feature") == IssueType.FEATURE
assert IssueType.from_label("enhancement") == IssueType.ENHANCEMENT
assert IssueType.from_label("task") == IssueType.TASK
assert IssueType.from_label("documentation") == IssueType.DOCUMENTATION
assert IssueType.from_label("question") == IssueType.QUESTION
def test_from_label_case_insensitive(self):
"""Test case insensitive type matching."""
assert IssueType.from_label("BUG") == IssueType.BUG
assert IssueType.from_label("Bug") == IssueType.BUG
def test_from_label_invalid_type(self):
"""Test invalid type returns None."""
assert IssueType.from_label("invalid") is None
@pytest.mark.unit
class TestLabel:
"""Test Label model."""
def test_label_creation(self):
"""Test creating a label."""
label = Label(name="bug", color="red", description="Bug reports")
assert label.name == "bug"
assert label.color == "red"
assert label.description == "Bug reports"
def test_label_category_priority(self):
"""Test priority label categorization."""
label = Label(name="priority:high")
assert label.category == "priority"
def test_label_category_status(self):
"""Test status label categorization."""
label = Label(name="status:in-progress")
assert label.category == "status"
def test_label_category_type_with_prefix(self):
"""Test type label with prefix categorization."""
label = Label(name="type:bug")
assert label.category == "type"
def test_label_category_type_without_prefix(self):
"""Test type label without prefix categorization."""
label = Label(name="bug")
assert label.category == "type"
label2 = Label(name="feature")
assert label2.category == "type"
def test_label_category_other(self):
"""Test other label categorization."""
label = Label(name="good-first-issue")
assert label.category == "other"
def test_label_priority_property(self):
"""Test extracting priority from label."""
label = Label(name="priority:high")
assert label.priority == Priority.HIGH
label2 = Label(name="bug")
assert label2.priority is None
def test_label_issue_type_property(self):
"""Test extracting issue type from label."""
label = Label(name="bug")
assert label.issue_type == IssueType.BUG
label2 = Label(name="priority:high")
assert label2.issue_type is None
@pytest.mark.unit
class TestUser:
"""Test User model."""
def test_user_creation(self):
"""Test creating a user."""
user = User(
id="user123",
username="alice",
display_name="Alice Smith",
email="alice@example.com"
)
assert user.id == "user123"
assert user.username == "alice"
assert user.display_name == "Alice Smith"
assert user.email == "alice@example.com"
@pytest.mark.unit
class TestMilestone:
"""Test Milestone model."""
def test_milestone_creation(self):
"""Test creating a milestone."""
now = datetime.now(timezone.utc)
milestone = Milestone(
id="m1",
title="v1.0",
description="First release",
state="open",
due_date=now,
created_at=now,
updated_at=now
)
assert milestone.id == "m1"
assert milestone.title == "v1.0"
assert milestone.state == "open"
@pytest.mark.unit
class TestComment:
"""Test Comment model."""
def test_comment_creation(self):
"""Test creating a comment."""
author = User(id="user1", username="alice")
now = datetime.now(timezone.utc)
comment = Comment(
id="c1",
body="Great issue!",
author=author,
created_at=now
)
assert comment.id == "c1"
assert comment.body == "Great issue!"
assert comment.author.username == "alice"
@pytest.mark.unit
class TestIssueBasics:
"""Test basic Issue model functionality."""
def test_issue_creation(self):
"""Test creating an issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1",
number=1,
title="Test Issue",
description="Test description",
state=IssueState.OPEN,
created_at=now,
updated_at=now
)
assert issue.id == "issue1"
assert issue.number == 1
assert issue.title == "Test Issue"
assert issue.state == IssueState.OPEN
def test_issue_with_labels(self):
"""Test creating issue with labels."""
now = datetime.now(timezone.utc)
labels = [
Label(name="bug", color="red"),
Label(name="priority:high", color="orange")
]
issue = Issue(
id="issue1", number=1, title="Bug", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels
)
assert len(issue.labels) == 2
def test_issue_with_assignees(self):
"""Test creating issue with assignees."""
now = datetime.now(timezone.utc)
assignees = [User(id="u1", username="alice")]
issue = Issue(
id="issue1", number=1, title="Task", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
assignees=assignees
)
assert len(issue.assignees) == 1
assert issue.primary_assignee.username == "alice"
def test_primary_assignee_when_none(self):
"""Test primary_assignee returns None when no assignees."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Task", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now
)
assert issue.primary_assignee is None
@pytest.mark.unit
class TestIssueLabelCategorization:
"""Test Issue label categorization."""
def test_label_categories_property(self):
"""Test label_categories property organizes labels."""
now = datetime.now(timezone.utc)
labels = [
Label(name="bug"),
Label(name="priority:high"),
Label(name="status:in-review"),
Label(name="good-first-issue")
]
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels
)
categories = issue.label_categories
assert len(categories.type_labels) == 1
assert len(categories.priority_labels) == 1
assert len(categories.status_labels) == 1
assert len(categories.other_labels) == 1
def test_issue_priority_property(self):
"""Test issue.priority extracts priority from labels."""
now = datetime.now(timezone.utc)
labels = [Label(name="priority:high"), Label(name="bug")]
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels
)
assert issue.priority == Priority.HIGH
def test_issue_priority_none_when_no_priority_label(self):
"""Test issue.priority is None without priority label."""
now = datetime.now(timezone.utc)
labels = [Label(name="bug")]
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels
)
assert issue.priority is None
def test_issue_type_property(self):
"""Test issue.issue_type extracts type from labels."""
now = datetime.now(timezone.utc)
labels = [Label(name="bug"), Label(name="priority:high")]
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels
)
assert issue.issue_type == IssueType.BUG
def test_cache_invalidation(self):
"""Test label cache is invalidated when labels change."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[Label(name="bug")]
)
# Access to populate cache
assert issue.issue_type == IssueType.BUG
# Manually modify labels
issue.labels = [Label(name="feature")]
issue.invalidate_cache()
# Should reflect new labels
assert issue.issue_type == IssueType.FEATURE
@pytest.mark.unit
class TestIssueStateTransitions:
"""Test Issue state transition methods."""
def test_close_issue(self):
"""Test closing an open issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now
)
issue.close()
assert issue.state == IssueState.CLOSED
assert issue.closed_at is not None
def test_close_issue_with_custom_timestamp(self):
"""Test closing issue with custom timestamp."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now
)
custom_time = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
issue.close(closed_at=custom_time)
assert issue.closed_at == custom_time
def test_close_already_closed_issue_raises_error(self):
"""Test closing already closed issue raises error."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.CLOSED, created_at=now, updated_at=now,
closed_at=now
)
with pytest.raises(ValueError, match="already closed"):
issue.close()
def test_reopen_issue(self):
"""Test reopening a closed issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.CLOSED, created_at=now, updated_at=now,
closed_at=now
)
issue.reopen()
assert issue.state == IssueState.OPEN
assert issue.closed_at is None
def test_reopen_open_issue_raises_error(self):
"""Test reopening non-closed issue raises error."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now
)
with pytest.raises(ValueError, match="not closed"):
issue.reopen()
@pytest.mark.unit
class TestIssueLabelMethods:
"""Test Issue label manipulation methods."""
def test_add_label(self):
"""Test adding a label to issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[]
)
bug_label = Label(name="bug", color="red")
issue.add_label(bug_label)
assert len(issue.labels) == 1
assert issue.labels[0].name == "bug"
def test_add_duplicate_label_ignored(self):
"""Test adding duplicate label is ignored."""
now = datetime.now(timezone.utc)
bug_label = Label(name="bug", color="red")
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[bug_label]
)
issue.add_label(bug_label)
assert len(issue.labels) == 1
def test_remove_label(self):
"""Test removing a label from issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[Label(name="bug"), Label(name="feature")]
)
result = issue.remove_label("bug")
assert result is True
assert len(issue.labels) == 1
assert issue.labels[0].name == "feature"
def test_remove_nonexistent_label_returns_false(self):
"""Test removing non-existent label returns False."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[Label(name="bug")]
)
result = issue.remove_label("nonexistent")
assert result is False
assert len(issue.labels) == 1
def test_has_label(self):
"""Test checking if issue has a label."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=[Label(name="bug"), Label(name="priority:high")]
)
assert issue.has_label("bug")
assert issue.has_label("priority:high")
assert not issue.has_label("feature")
@pytest.mark.unit
class TestIssueAssigneeMethods:
"""Test Issue assignee manipulation methods."""
def test_add_assignee(self):
"""Test adding an assignee to issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
assignees=[]
)
user = User(id="u1", username="alice")
issue.add_assignee(user)
assert len(issue.assignees) == 1
assert issue.assignees[0].username == "alice"
def test_add_duplicate_assignee_ignored(self):
"""Test adding duplicate assignee is ignored."""
now = datetime.now(timezone.utc)
user = User(id="u1", username="alice")
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
assignees=[user]
)
issue.add_assignee(user)
assert len(issue.assignees) == 1
def test_remove_assignee(self):
"""Test removing an assignee from issue."""
now = datetime.now(timezone.utc)
user1 = User(id="u1", username="alice")
user2 = User(id="u2", username="bob")
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
assignees=[user1, user2]
)
result = issue.remove_assignee("u1")
assert result is True
assert len(issue.assignees) == 1
assert issue.assignees[0].username == "bob"
def test_remove_nonexistent_assignee_returns_false(self):
"""Test removing non-existent assignee returns False."""
now = datetime.now(timezone.utc)
user = User(id="u1", username="alice")
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
assignees=[user]
)
result = issue.remove_assignee("nonexistent")
assert result is False
assert len(issue.assignees) == 1
@pytest.mark.unit
class TestIssueCommentMethods:
"""Test Issue comment methods."""
def test_add_comment(self):
"""Test adding a comment to issue."""
now = datetime.now(timezone.utc)
issue = Issue(
id="issue1", number=1, title="Test", description="Desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
comments=[]
)
author = User(id="u1", username="alice")
comment = Comment(id="c1", body="Great!", author=author, created_at=now)
issue.add_comment(comment)
assert len(issue.comments) == 1
assert issue.comments[0].body == "Great!"
@pytest.mark.unit
class TestIssueSerialization:
"""Test Issue serialization."""
def test_to_dict(self):
"""Test converting issue to dictionary."""
now = datetime.now(timezone.utc)
labels = [Label(name="bug", color="red")]
assignees = [User(id="u1", username="alice", display_name="Alice")]
milestone = Milestone(id="m1", title="v1.0")
issue = Issue(
id="issue1", number=1, title="Test Issue", description="Test desc",
state=IssueState.OPEN, created_at=now, updated_at=now,
labels=labels, assignees=assignees, milestone=milestone,
backend_id="gitea-123", backend_type="gitea"
)
issue_dict = issue.to_dict()
assert issue_dict['id'] == "issue1"
assert issue_dict['number'] == 1
assert issue_dict['title'] == "Test Issue"
assert issue_dict['state'] == "open"
assert issue_dict['backend_id'] == "gitea-123"
assert issue_dict['backend_type'] == "gitea"
assert len(issue_dict['labels']) == 1
assert len(issue_dict['assignees']) == 1
assert issue_dict['milestone']['id'] == "m1"

View File

@@ -1,531 +0,0 @@
"""
Tests for Gitea Issue/Milestone/Label Management Integration
This test suite provides comprehensive coverage of Gitea API operations for
issue tracking, including issues, milestones, and labels.
The issue-facade capability provides a unified interface to various issue
tracking backends, including Gitea. This test suite covers the complete
Gitea API integration layer.
Test Coverage:
- **GiteaConfig**: Configuration and API URL generation
- **IssuesClient**: Full issue CRUD operations, labels, milestones
- **MilestonesClient**: Milestone creation and management
- **LabelsClient**: Label operations
- **GiteaClient**: Main client facade
- **Error Handling**: Error propagation and handling
- **Integration Patterns**: API consistency and compatibility
Current Status: SKIPPED - Tests need updating for issue-facade architecture
Related Code: capabilities/issue-facade/issue_tracker/backends/gitea/
Note: These tests were originally written for a different Gitea client
architecture. They need to be updated to work with the issue-facade backend
pattern (see test_gitea_backend.py for the current backend tests).
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from datetime import datetime
# Skip all tests - need to update for issue-facade architecture
# These tests are for a different Gitea client pattern than currently implemented
pytestmark = pytest.mark.skip(
reason="Tests need updating for issue-facade backend architecture. "
"See test_gitea_backend.py for current Gitea backend tests."
)
class TestGiteaConfig:
"""Test GiteaConfig functionality."""
def test_config_creation(self):
"""Test basic config creation."""
config = GiteaConfig(
gitea_url="https://gitea.example.com",
repo_owner="test_owner",
repo_name="test_repo",
auth_token="test_token"
)
assert config.gitea_url == "https://gitea.example.com"
assert config.repo_owner == "test_owner"
assert config.repo_name == "test_repo"
assert config.auth_token == "test_token"
def test_api_url_properties(self):
"""Test API URL property generation."""
config = GiteaConfig(
gitea_url="https://gitea.example.com",
repo_owner="test_owner",
repo_name="test_repo"
)
assert config.base_api_url == "https://gitea.example.com/api/v1"
assert config.repo_api_url == "https://gitea.example.com/api/v1/repos/test_owner/test_repo"
assert config.issues_api_url == "https://gitea.example.com/api/v1/repos/test_owner/test_repo/issues"
@patch('gitea.config.subprocess.run')
def test_from_git_repository(self, mock_run):
"""Test config creation from git repository."""
mock_run.return_value = Mock(
stdout="https://gitea.example.com/owner/repo.git",
returncode=0
)
config = GiteaConfig.from_git_repository()
assert config.gitea_url == "https://gitea.example.com"
assert config.repo_owner == "owner"
assert config.repo_name == "repo"
def test_config_validation(self):
"""Test config validation."""
# Valid config should not raise
config = GiteaConfig(
gitea_url="https://gitea.example.com",
repo_owner="owner",
repo_name="repo"
)
config.validate() # Should not raise
# Invalid URL should raise
invalid_config = GiteaConfig(
gitea_url="invalid-url",
repo_owner="owner",
repo_name="repo"
)
with pytest.raises(Exception):
invalid_config.validate()
class TestIssuesClient:
"""Test IssuesClient functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_api = Mock()
self.client = IssuesClient(self.mock_api)
# Mock issue for responses
self.mock_issue = Mock(spec=Issue)
self.mock_issue.number = 1
self.mock_issue.title = "Test Issue"
self.mock_issue.body = "Test body"
self.mock_issue.state = "open"
self.mock_issue.html_url = "https://gitea.example.com/owner/repo/issues/1"
self.mock_issue.created_at = datetime(2023, 1, 1, 12, 0, 0)
self.mock_issue.updated_at = datetime(2023, 1, 1, 12, 0, 0)
self.mock_issue.assignee = None
self.mock_issue.labels = []
self.mock_issue.milestone = None
def test_get_issue(self):
"""Test getting a single issue."""
self.mock_api.get_issue.return_value = self.mock_issue
result = self.client.get(1)
assert result == self.mock_issue
self.mock_api.get_issue.assert_called_once_with(1)
def test_list_issues(self):
"""Test listing issues."""
self.mock_api.list_issues.return_value = [self.mock_issue]
result = self.client.list()
assert result == [self.mock_issue]
self.mock_api.list_issues.assert_called_once_with("all", 1, 50)
def test_list_issues_with_filters(self):
"""Test listing issues with filters."""
self.mock_api.list_issues.return_value = [self.mock_issue]
result = self.client.list(state="open", page=2, per_page=25)
assert result == [self.mock_issue]
self.mock_api.list_issues.assert_called_once_with("open", 2, 25)
def test_create_issue(self):
"""Test creating an issue."""
self.mock_api.create_issue.return_value = self.mock_issue
result = self.client.create("Test Title", "Test Body")
assert result == self.mock_issue
self.mock_api.create_issue.assert_called_once()
def test_create_issue_with_options(self):
"""Test creating an issue with optional fields."""
self.mock_api.create_issue.return_value = self.mock_issue
result = self.client.create(
"Test Title",
"Test Body",
assignees=["user1"],
milestone=1,
labels=["bug", "priority:high"]
)
assert result == self.mock_issue
self.mock_api.create_issue.assert_called_once()
def test_update_issue(self):
"""Test updating an issue."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.update(1, title="New Title")
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_close_issue(self):
"""Test closing an issue."""
closed_issue = Mock(spec=Issue)
closed_issue.state = "closed"
self.mock_api.update_issue.return_value = closed_issue
result = self.client.close(1)
assert result.state == "closed"
self.mock_api.update_issue.assert_called_once()
def test_reopen_issue(self):
"""Test reopening an issue."""
opened_issue = Mock(spec=Issue)
opened_issue.state = "open"
self.mock_api.update_issue.return_value = opened_issue
result = self.client.reopen(1)
assert result.state == "open"
self.mock_api.update_issue.assert_called_once()
def test_add_labels(self):
"""Test adding labels to an issue."""
# Mock getting current issue
self.mock_issue.labels = [Mock(name="existing")]
self.mock_api.get_issue.return_value = self.mock_issue
# Mock update result
updated_issue = Mock(spec=Issue)
updated_issue.labels = [Mock(name="existing"), Mock(name="new")]
self.mock_api.update_issue.return_value = updated_issue
result = self.client.add_labels(1, ["new"])
assert len(result.labels) == 2
self.mock_api.get_issue.assert_called_once_with(1)
self.mock_api.update_issue.assert_called_once()
def test_remove_labels(self):
"""Test removing labels from an issue."""
# Mock getting current issue
label1 = Mock(name="keep")
label2 = Mock(name="remove")
self.mock_issue.labels = [label1, label2]
self.mock_api.get_issue.return_value = self.mock_issue
# Mock update result
updated_issue = Mock(spec=Issue)
updated_issue.labels = [label1]
self.mock_api.update_issue.return_value = updated_issue
result = self.client.remove_labels(1, ["remove"])
assert len(result.labels) == 1
self.mock_api.get_issue.assert_called_once_with(1)
self.mock_api.update_issue.assert_called_once()
def test_assign_to_milestone(self):
"""Test assigning issue to milestone."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.assign_to_milestone(1, 5)
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_remove_from_milestone(self):
"""Test removing issue from milestone."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.remove_from_milestone(1)
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_set_labels(self):
"""Test replacing all labels on an issue."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.set_labels(1, ["bug", "priority:high"])
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_update_title(self):
"""Test updating only issue title."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.update_title(1, "New Title")
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_update_body(self):
"""Test updating only issue body."""
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.update_body(1, "New Body")
assert result == self.mock_issue
self.mock_api.update_issue.assert_called_once()
def test_set_priority(self):
"""Test setting issue priority."""
# Mock getting current issue
self.mock_issue.labels = [Mock(name="bug")]
self.mock_api.get_issue.return_value = self.mock_issue
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.set_priority(1, Priority.HIGH)
assert result == self.mock_issue
self.mock_api.get_issue.assert_called_once_with(1)
self.mock_api.update_issue.assert_called_once()
def test_set_status(self):
"""Test setting issue status."""
# Mock getting current issue
self.mock_issue.labels = [Mock(name="bug")]
self.mock_api.get_issue.return_value = self.mock_issue
self.mock_api.update_issue.return_value = self.mock_issue
result = self.client.set_status(1, ProjectState.ACTIVE)
assert result == self.mock_issue
self.mock_api.get_issue.assert_called_once_with(1)
self.mock_api.update_issue.assert_called_once()
def test_to_dict(self):
"""Test converting issue to dictionary."""
result = self.client.to_dict(self.mock_issue)
expected_keys = ['number', 'title', 'body', 'state', 'html_url',
'created_at', 'updated_at', 'assignee', 'labels', 'milestone']
assert all(key in result for key in expected_keys)
assert result['number'] == 1
assert result['title'] == "Test Issue"
assert result['state'] == "open"
class TestMilestonesClient:
"""Test MilestonesClient functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_api = Mock()
self.client = MilestonesClient(self.mock_api)
self.mock_milestone = Mock(spec=Milestone)
self.mock_milestone.id = 1
self.mock_milestone.title = "Test Milestone"
def test_list_milestones(self):
"""Test listing milestones."""
self.mock_api.list_milestones.return_value = [self.mock_milestone]
result = self.client.list()
assert result == [self.mock_milestone]
self.mock_api.list_milestones.assert_called_once_with("all")
def test_list_open_milestones(self):
"""Test listing open milestones."""
self.mock_api.list_milestones.return_value = [self.mock_milestone]
result = self.client.list_open()
assert result == [self.mock_milestone]
self.mock_api.list_milestones.assert_called_once_with("open")
def test_create_milestone(self):
"""Test creating a milestone."""
self.mock_api.create_milestone.return_value = self.mock_milestone
result = self.client.create("Test Milestone", "Description")
assert result == self.mock_milestone
self.mock_api.create_milestone.assert_called_once()
class TestLabelsClient:
"""Test LabelsClient functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_api = Mock()
self.client = LabelsClient(self.mock_api)
self.mock_label = Mock(spec=Label)
self.mock_label.id = 1
self.mock_label.name = "bug"
def test_list_labels(self):
"""Test listing labels."""
self.mock_api.list_labels.return_value = [self.mock_label]
result = self.client.list()
assert result == [self.mock_label]
self.mock_api.list_labels.assert_called_once()
def test_create_label(self):
"""Test creating a label."""
self.mock_api.create_label.return_value = self.mock_label
result = self.client.create("bug", "red", "Bug reports")
assert result == self.mock_label
self.mock_api.create_label.assert_called_once()
class TestGiteaClient:
"""Test the main GiteaClient facade."""
@patch('gitea.client.GiteaApiClient')
def test_client_initialization(self, mock_api_client):
"""Test GiteaClient initialization."""
config = GiteaConfig(
gitea_url="https://gitea.example.com",
repo_owner="test_owner",
repo_name="test_repo"
)
client = GiteaClient(config)
assert isinstance(client.issues, IssuesClient)
assert isinstance(client.milestones, MilestonesClient)
assert isinstance(client.labels, LabelsClient)
mock_api_client.assert_called_once_with(config)
@patch('gitea.client.GiteaConfig.from_git_repository')
@patch('gitea.client.GiteaApiClient')
def test_client_auto_config(self, mock_api_client, mock_from_git):
"""Test GiteaClient with auto-detected config."""
mock_config = Mock()
mock_from_git.return_value = mock_config
client = GiteaClient()
mock_from_git.assert_called_once()
mock_api_client.assert_called_once_with(mock_config)
class TestErrorHandling:
"""Test error handling throughout the facade."""
def setup_method(self):
"""Set up test fixtures."""
self.mock_api = Mock()
self.client = IssuesClient(self.mock_api)
def test_gitea_error_propagation(self):
"""Test that GiteaError is properly propagated."""
self.mock_api.get_issue.side_effect = GiteaError("API Error")
with pytest.raises(GiteaError):
self.client.get(1)
def test_not_found_error_propagation(self):
"""Test that GiteaNotFoundError is properly propagated."""
self.mock_api.get_issue.side_effect = GiteaNotFoundError("Issue not found")
with pytest.raises(GiteaNotFoundError):
self.client.get(999)
def test_auth_error_propagation(self):
"""Test that GiteaAuthError is properly propagated."""
self.mock_api.create_issue.side_effect = GiteaAuthError("Unauthorized")
with pytest.raises(GiteaAuthError):
self.client.create("Title", "Body")
class TestIntegrationPatterns:
"""Test integration patterns and best practices."""
@patch('gitea.client.GiteaApiClient')
def test_consistent_interface(self, mock_api_client):
"""Test that the facade provides consistent interfaces."""
config = GiteaConfig(gitea_url="https://gitea.example.com",
repo_owner="owner", repo_name="repo")
client = GiteaClient(config)
# All sub-clients should be available
assert hasattr(client, 'issues')
assert hasattr(client, 'milestones')
assert hasattr(client, 'labels')
# All should have consistent method patterns
assert hasattr(client.issues, 'list')
assert hasattr(client.issues, 'get')
assert hasattr(client.issues, 'create')
assert hasattr(client.issues, 'update')
assert hasattr(client.milestones, 'list')
assert hasattr(client.milestones, 'create')
assert hasattr(client.labels, 'list')
assert hasattr(client.labels, 'create')
def test_backward_compatibility_dict_conversion(self):
"""Test that to_dict provides backward compatibility."""
mock_api = Mock()
client = IssuesClient(mock_api)
# Create a mock issue with all expected attributes
mock_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_issue.title = "Test"
mock_issue.body = "Body"
mock_issue.state = "open"
mock_issue.html_url = "https://example.com"
mock_issue.created_at = datetime(2023, 1, 1)
mock_issue.updated_at = datetime(2023, 1, 1)
mock_issue.assignee = None
mock_issue.labels = []
mock_issue.milestone = None
result = client.to_dict(mock_issue)
# Should contain all expected fields for backward compatibility
required_fields = ['number', 'title', 'body', 'state', 'html_url',
'created_at', 'updated_at', 'assignee', 'labels', 'milestone']
for field in required_fields:
assert field in result, f"Missing required field: {field}"
def test_label_operations_consistency(self):
"""Test that label operations work consistently."""
mock_api = Mock()
client = IssuesClient(mock_api)
# Mock issue with labels
mock_issue = Mock()
mock_issue.labels = [Mock(name="bug"), Mock(name="priority:high")]
mock_api.get_issue.return_value = mock_issue
mock_api.update_issue.return_value = mock_issue
# Test all label operations
client.add_labels(1, ["new-label"])
client.remove_labels(1, ["old-label"])
client.set_labels(1, ["label1", "label2"])
# Should have made appropriate API calls
assert mock_api.get_issue.call_count == 2 # add_labels and remove_labels
assert mock_api.update_issue.call_count == 3 # all three operations

751
tests/test_local_backend.py Normal file
View File

@@ -0,0 +1,751 @@
"""
Test suite for Local SQLite Backend functionality.
These tests ensure the local backend correctly implements the IssueBackend interface
and provides reliable offline issue tracking.
"""
import pytest
import tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
from issue_tracker.backends.local.backend import LocalSQLiteBackend
from issue_tracker.core.models import Issue, Label, User, Milestone, Comment, IssueState
from issue_tracker.core.interfaces import IssueFilter
@pytest.mark.unit
class TestLocalBackendInitialization:
"""Test local backend initialization and connection."""
def test_backend_initialization(self):
"""Test backend initializes with correct type and capabilities."""
backend = LocalSQLiteBackend()
assert backend.backend_type == "local"
assert backend.capabilities.supports_milestones
assert backend.capabilities.supports_assignees
assert backend.capabilities.supports_comments
assert backend.capabilities.supports_labels
assert backend.capabilities.supports_search
assert backend.capabilities.supports_bulk_operations
assert not backend.capabilities.supports_webhooks
assert not backend.capabilities.supports_real_time
def test_connect_creates_database(self):
"""Test connect creates database file and schema."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(db_path)})
assert db_path.exists()
assert backend.connection is not None
assert backend.test_connection()
backend.disconnect()
def test_disconnect_closes_connection(self):
"""Test disconnect properly closes database connection."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
backend.disconnect()
assert backend.connection is None
assert not backend.test_connection()
def test_schema_initialization(self):
"""Test database schema is properly initialized."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
# Verify tables exist
cursor = backend.connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = {row[0] for row in cursor.fetchall()}
expected_tables = {
'issues', 'labels', 'users', 'milestones', 'comments',
'issue_labels', 'issue_assignees', 'sync_history'
}
assert expected_tables.issubset(tables)
backend.disconnect()
@pytest.mark.unit
class TestIssueCRUD:
"""Test issue CRUD operations."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_issue(self, backend):
"""Test creating a new issue."""
issue = Issue(
id=None,
number=0,
title="Test Issue",
description="Test description",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created = backend.create_issue(issue)
assert created.id is not None
assert created.number == 1
assert created.title == "Test Issue"
assert created.description == "Test description"
assert created.state == IssueState.OPEN
def test_create_multiple_issues_increments_numbers(self, backend):
"""Test issue numbers increment correctly."""
issue1 = Issue(
id=None, number=0, title="Issue 1", description="Desc 1",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
issue2 = Issue(
id=None, number=0, title="Issue 2", description="Desc 2",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created1 = backend.create_issue(issue1)
created2 = backend.create_issue(issue2)
assert created1.number == 1
assert created2.number == 2
def test_get_issue_by_id(self, backend):
"""Test retrieving issue by ID."""
issue = Issue(
id=None, number=0, title="Test", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created = backend.create_issue(issue)
retrieved = backend.get_issue(created.id)
assert retrieved is not None
assert retrieved.id == created.id
assert retrieved.title == "Test"
def test_get_issue_by_number(self, backend):
"""Test retrieving issue by number."""
issue = Issue(
id=None, number=0, title="Test", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created = backend.create_issue(issue)
retrieved = backend.get_issue_by_number(created.number)
assert retrieved is not None
assert retrieved.number == created.number
assert retrieved.title == "Test"
def test_get_nonexistent_issue_returns_none(self, backend):
"""Test getting non-existent issue returns None."""
assert backend.get_issue("nonexistent-id") is None
assert backend.get_issue_by_number(999) is None
def test_update_issue(self, backend):
"""Test updating an existing issue."""
issue = Issue(
id=None, number=0, title="Original", description="Original desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created = backend.create_issue(issue)
created.title = "Updated"
created.description = "Updated desc"
created.state = IssueState.CLOSED
created.closed_at = datetime.now(timezone.utc)
updated = backend.update_issue(created)
assert updated.title == "Updated"
assert updated.description == "Updated desc"
assert updated.state == IssueState.CLOSED
# Verify changes persisted
retrieved = backend.get_issue(created.id)
assert retrieved.title == "Updated"
def test_delete_issue(self, backend):
"""Test deleting an issue."""
issue = Issue(
id=None, number=0, title="To Delete", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
created = backend.create_issue(issue)
result = backend.delete_issue(created.id)
assert result is True
assert backend.get_issue(created.id) is None
def test_delete_nonexistent_issue_returns_false(self, backend):
"""Test deleting non-existent issue returns False."""
result = backend.delete_issue("nonexistent-id")
assert result is False
@pytest.mark.unit
class TestIssueWithLabels:
"""Test issue operations with labels."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_issue_with_labels(self, backend):
"""Test creating issue with labels."""
labels = [
Label(name="bug", color="red", description="Bug reports"),
Label(name="priority:high", color="orange")
]
issue = Issue(
id=None, number=0, title="Bug Report", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
labels=labels
)
created = backend.create_issue(issue)
retrieved = backend.get_issue(created.id)
assert len(retrieved.labels) == 2
assert any(l.name == "bug" for l in retrieved.labels)
assert any(l.name == "priority:high" for l in retrieved.labels)
def test_update_issue_labels(self, backend):
"""Test updating issue labels."""
issue = Issue(
id=None, number=0, title="Test", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
labels=[Label(name="bug")]
)
created = backend.create_issue(issue)
# Update labels
created.labels = [
Label(name="bug"),
Label(name="enhancement"),
Label(name="priority:low")
]
backend.update_issue(created)
retrieved = backend.get_issue(created.id)
assert len(retrieved.labels) == 3
@pytest.mark.unit
class TestIssueWithAssignees:
"""Test issue operations with assignees."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_issue_with_assignees(self, backend):
"""Test creating issue with assignees."""
assignees = [
User(id="user1", username="alice", display_name="Alice"),
User(id="user2", username="bob", display_name="Bob")
]
issue = Issue(
id=None, number=0, title="Assigned Issue", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
assignees=assignees
)
created = backend.create_issue(issue)
retrieved = backend.get_issue(created.id)
assert len(retrieved.assignees) == 2
assert any(u.username == "alice" for u in retrieved.assignees)
assert any(u.username == "bob" for u in retrieved.assignees)
@pytest.mark.unit
class TestIssueWithMilestone:
"""Test issue operations with milestones."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_issue_with_milestone(self, backend):
"""Test creating issue with milestone."""
milestone = Milestone(
id=None,
title="v1.0",
description="First release",
state="open",
due_date=datetime.now(timezone.utc) + timedelta(days=30)
)
created_milestone = backend.create_milestone(milestone)
issue = Issue(
id=None, number=0, title="Feature", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
milestone=created_milestone
)
created_issue = backend.create_issue(issue)
retrieved = backend.get_issue(created_issue.id)
assert retrieved.milestone is not None
assert retrieved.milestone.title == "v1.0"
@pytest.mark.unit
class TestListAndFilter:
"""Test listing and filtering issues."""
@pytest.fixture
def backend(self):
"""Create backend with sample data."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
# Create sample issues
backend.create_issue(Issue(
id=None, number=0, title="Open Bug", description="Bug desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
labels=[Label(name="bug")]
))
backend.create_issue(Issue(
id=None, number=0, title="Closed Feature", description="Feature desc",
state=IssueState.CLOSED,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc),
labels=[Label(name="enhancement")]
))
backend.create_issue(Issue(
id=None, number=0, title="In Progress Task", description="Task desc",
state=IssueState.IN_PROGRESS,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
))
yield backend
backend.disconnect()
def test_list_all_issues(self, backend):
"""Test listing all issues."""
issues = backend.list_issues()
assert len(issues) == 3
def test_filter_by_state_open(self, backend):
"""Test filtering by open state."""
filter_criteria = IssueFilter(state="open")
issues = backend.list_issues(filter_criteria)
assert len(issues) == 1
assert issues[0].state == IssueState.OPEN
def test_filter_by_state_closed(self, backend):
"""Test filtering by closed state."""
filter_criteria = IssueFilter(state="closed")
issues = backend.list_issues(filter_criteria)
assert len(issues) == 1
assert issues[0].state == IssueState.CLOSED
def test_search_issues(self, backend):
"""Test searching issues by text."""
filter_criteria = IssueFilter(search="Bug")
issues = backend.list_issues(filter_criteria)
assert len(issues) == 1
assert "Bug" in issues[0].title
def test_search_issues_method(self, backend):
"""Test search_issues method."""
issues = backend.search_issues("Feature")
assert len(issues) == 1
assert "Feature" in issues[0].title
def test_filter_with_limit(self, backend):
"""Test filtering with limit."""
filter_criteria = IssueFilter(limit=2)
issues = backend.list_issues(filter_criteria)
assert len(issues) == 2
def test_filter_with_offset(self, backend):
"""Test filtering with offset and limit."""
filter_criteria = IssueFilter(limit=2, offset=1)
issues = backend.list_issues(filter_criteria)
assert len(issues) == 2
@pytest.mark.unit
class TestLabelOperations:
"""Test label management operations."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_label(self, backend):
"""Test creating a label."""
label = Label(name="bug", color="red", description="Bug reports")
created = backend.create_label(label)
assert created.name == "bug"
assert created.color == "red"
def test_get_labels(self, backend):
"""Test getting all labels."""
backend.create_label(Label(name="bug", color="red"))
backend.create_label(Label(name="enhancement", color="blue"))
labels = backend.get_labels()
assert len(labels) == 2
def test_update_label(self, backend):
"""Test updating a label."""
label = Label(name="bug", color="red", description="Old desc")
backend.create_label(label)
# Create new label with updated values (Label is frozen/immutable)
updated_label = Label(name="bug", color="orange", description="New desc")
backend.update_label(updated_label)
labels = backend.get_labels()
bug_label = next(l for l in labels if l.name == "bug")
assert bug_label.color == "orange"
assert bug_label.description == "New desc"
def test_delete_label(self, backend):
"""Test deleting a label."""
backend.create_label(Label(name="bug", color="red"))
result = backend.delete_label("bug")
assert result is True
labels = backend.get_labels()
assert len(labels) == 0
@pytest.mark.unit
class TestUserOperations:
"""Test user management operations."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
# Create test users
backend.connection.execute("""
INSERT INTO users (id, username, display_name, email)
VALUES (?, ?, ?, ?)
""", ("user1", "alice", "Alice Smith", "alice@example.com"))
backend.connection.execute("""
INSERT INTO users (id, username, display_name, email)
VALUES (?, ?, ?, ?)
""", ("user2", "bob", "Bob Jones", "bob@example.com"))
backend.connection.commit()
yield backend
backend.disconnect()
def test_get_users(self, backend):
"""Test getting all users."""
users = backend.get_users()
assert len(users) == 2
def test_get_user_by_id(self, backend):
"""Test getting user by ID."""
user = backend.get_user("user1")
assert user is not None
assert user.username == "alice"
def test_search_users(self, backend):
"""Test searching users."""
users = backend.search_users("alice")
assert len(users) == 1
assert users[0].username == "alice"
@pytest.mark.unit
class TestMilestoneOperations:
"""Test milestone management operations."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_create_milestone(self, backend):
"""Test creating a milestone."""
milestone = Milestone(
id=None,
title="v1.0",
description="First release",
state="open",
due_date=datetime.now(timezone.utc) + timedelta(days=30)
)
created = backend.create_milestone(milestone)
assert created.id is not None
assert created.title == "v1.0"
def test_get_milestones(self, backend):
"""Test getting all milestones."""
backend.create_milestone(Milestone(id=None, title="v1.0", state="open"))
backend.create_milestone(Milestone(id=None, title="v2.0", state="open"))
milestones = backend.get_milestones()
assert len(milestones) == 2
def test_update_milestone(self, backend):
"""Test updating a milestone."""
milestone = Milestone(id=None, title="v1.0", description="Old", state="open")
created = backend.create_milestone(milestone)
created.description = "Updated"
created.state = "closed"
backend.update_milestone(created)
milestones = backend.get_milestones()
updated = next(m for m in milestones if m.id == created.id)
assert updated.description == "Updated"
assert updated.state == "closed"
def test_delete_milestone(self, backend):
"""Test deleting a milestone."""
milestone = Milestone(id=None, title="v1.0", state="open")
created = backend.create_milestone(milestone)
result = backend.delete_milestone(created.id)
assert result is True
milestones = backend.get_milestones()
assert len(milestones) == 0
@pytest.mark.unit
class TestCommentOperations:
"""Test comment operations."""
@pytest.fixture
def backend(self):
"""Create backend with an issue."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
# Create test issue
issue = Issue(
id=None, number=0, title="Test", description="Desc",
state=IssueState.OPEN,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
backend.create_issue(issue)
yield backend
backend.disconnect()
def test_add_comment(self, backend):
"""Test adding a comment to an issue."""
issue = backend.get_issue_by_number(1)
author = User(id="user1", username="alice")
comment = Comment(
id=None,
body="Great issue!",
author=author,
created_at=datetime.now(timezone.utc)
)
created = backend.add_comment(issue.id, comment)
assert created.id is not None
assert created.body == "Great issue!"
def test_get_comments(self, backend):
"""Test getting comments for an issue."""
issue = backend.get_issue_by_number(1)
author = User(id="user1", username="alice")
comment1 = Comment(
id=None, body="First comment", author=author,
created_at=datetime.now(timezone.utc)
)
comment2 = Comment(
id=None, body="Second comment", author=author,
created_at=datetime.now(timezone.utc)
)
backend.add_comment(issue.id, comment1)
backend.add_comment(issue.id, comment2)
comments = backend.get_comments(issue.id)
assert len(comments) == 2
assert comments[0].body == "First comment"
def test_update_comment(self, backend):
"""Test updating a comment."""
issue = backend.get_issue_by_number(1)
author = User(id="user1", username="alice")
comment = Comment(
id=None, body="Original", author=author,
created_at=datetime.now(timezone.utc)
)
created = backend.add_comment(issue.id, comment)
created.body = "Updated"
backend.update_comment(created)
comments = backend.get_comments(issue.id)
assert comments[0].body == "Updated"
def test_delete_comment(self, backend):
"""Test deleting a comment."""
issue = backend.get_issue_by_number(1)
author = User(id="user1", username="alice")
comment = Comment(
id=None, body="To delete", author=author,
created_at=datetime.now(timezone.utc)
)
created = backend.add_comment(issue.id, comment)
result = backend.delete_comment(created.id)
assert result is True
comments = backend.get_comments(issue.id)
assert len(comments) == 0
@pytest.mark.unit
class TestSyncOperations:
"""Test synchronization support."""
@pytest.fixture
def backend(self):
"""Create a temporary backend for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
backend = LocalSQLiteBackend()
backend.connect({'db_path': str(Path(tmpdir) / "test.db")})
yield backend
backend.disconnect()
def test_finalize_sync_logs_success(self, backend):
"""Test successful sync is logged."""
backend.finalize_sync(success=True)
cursor = backend.connection.execute(
"SELECT * FROM sync_history WHERE success = 1"
)
rows = cursor.fetchall()
assert len(rows) == 1
def test_get_issues_modified_since(self, backend):
"""Test getting issues modified after a timestamp."""
old_time = datetime.now(timezone.utc) - timedelta(hours=2)
# Create issue before timestamp
issue1 = Issue(
id=None, number=0, title="Old", description="Desc",
state=IssueState.OPEN,
created_at=old_time,
updated_at=old_time
)
backend.create_issue(issue1)
# Create issue after timestamp
new_time = datetime.now(timezone.utc)
issue2 = Issue(
id=None, number=0, title="New", description="Desc",
state=IssueState.OPEN,
created_at=new_time,
updated_at=new_time
)
backend.create_issue(issue2)
# Get issues modified since 1 hour ago
since_time = datetime.now(timezone.utc) - timedelta(hours=1)
modified_issues = backend.get_issues_modified_since(since_time)
assert len(modified_issues) == 1
assert modified_issues[0].title == "New"
def test_get_sync_conflicts_returns_empty(self, backend):
"""Test local backend has no sync conflicts."""
conflicts = backend.get_sync_conflicts()
assert conflicts == []
def test_prepare_for_sync(self, backend):
"""Test prepare_for_sync doesn't fail."""
# Should not raise
backend.prepare_for_sync()