feat: Implement domain logic separation with clean architecture
- Created complete domain layer with pure business logic - Implemented Issue domain models with 48 passing tests - Implemented Project domain models with 31 passing tests - Added domain services for complex business operations - Established clean separation between domain, application, and infrastructure - All 250 tests passing with no breaking changes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
287
tests/unit/domain/issues/test_issue_models.py
Normal file
287
tests/unit/domain/issues/test_issue_models.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Unit tests for Issue domain models.
|
||||
|
||||
Tests pure business logic with no external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from domain.issues.models import Issue, Label, IssueState, LabelCategories
|
||||
from domain.issues.exceptions import IssueStateError
|
||||
|
||||
|
||||
class TestLabel:
|
||||
"""Test Label value object."""
|
||||
|
||||
def test_label_creation(self):
|
||||
# Arrange & Act
|
||||
label = Label(name="bug", color="#ff0000", description="Bug label")
|
||||
|
||||
# Assert
|
||||
assert label.name == "bug"
|
||||
assert label.color == "#ff0000"
|
||||
assert label.description == "Bug label"
|
||||
|
||||
def test_is_state_label(self):
|
||||
# Arrange
|
||||
state_label = Label("status:in-progress")
|
||||
regular_label = Label("bug")
|
||||
|
||||
# Act & Assert
|
||||
assert state_label.is_state_label() is True
|
||||
assert regular_label.is_state_label() is False
|
||||
|
||||
def test_is_priority_label(self):
|
||||
# Arrange
|
||||
priority_label = Label("priority:high")
|
||||
regular_label = Label("bug")
|
||||
|
||||
# Act & Assert
|
||||
assert priority_label.is_priority_label() is True
|
||||
assert regular_label.is_priority_label() is False
|
||||
|
||||
def test_is_type_label(self):
|
||||
# Arrange
|
||||
type_label = Label("bug")
|
||||
priority_label = Label("priority:high")
|
||||
|
||||
# Act & Assert
|
||||
assert type_label.is_type_label() is True
|
||||
assert priority_label.is_type_label() is False
|
||||
|
||||
@pytest.mark.parametrize("label_name,expected", [
|
||||
("bug", True),
|
||||
("enhancement", True),
|
||||
("feature", True),
|
||||
("documentation", True),
|
||||
("custom-label", False),
|
||||
("priority:high", False)
|
||||
])
|
||||
def test_type_label_recognition(self, label_name, expected):
|
||||
# Arrange
|
||||
label = Label(label_name)
|
||||
|
||||
# Act & Assert
|
||||
assert label.is_type_label() == expected
|
||||
|
||||
|
||||
class TestIssue:
|
||||
"""Test Issue aggregate root."""
|
||||
|
||||
def test_issue_creation_with_valid_data(self):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow()
|
||||
updated_at = datetime.utcnow()
|
||||
labels = [Label("bug"), Label("priority:high")]
|
||||
|
||||
# Act
|
||||
issue = Issue(
|
||||
number=123,
|
||||
title="Test Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=labels,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue.number == 123
|
||||
assert issue.title == "Test Issue"
|
||||
assert issue.state == IssueState.OPEN
|
||||
assert len(issue.labels) == 2
|
||||
assert issue.created_at == created_at
|
||||
assert issue.updated_at == updated_at
|
||||
|
||||
def test_categorize_labels_correctly_separates_types(self):
|
||||
# Arrange
|
||||
labels = [
|
||||
Label("bug"), # type label
|
||||
Label("priority:high"), # priority label
|
||||
Label("status:in-progress"), # state label
|
||||
Label("documentation"), # type label
|
||||
Label("custom-label") # other label
|
||||
]
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=labels,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
categories = issue.categorize_labels()
|
||||
|
||||
# Assert
|
||||
assert "bug" in categories.type_labels
|
||||
assert "documentation" in categories.type_labels
|
||||
assert "priority:high" in categories.priority_labels
|
||||
assert "status:in-progress" in categories.state_labels
|
||||
assert "custom-label" in categories.other_labels
|
||||
|
||||
def test_close_issue_changes_state_and_sets_closed_at(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.close()
|
||||
|
||||
# Assert
|
||||
assert issue.state == IssueState.CLOSED
|
||||
assert issue.closed_at is not None
|
||||
assert isinstance(issue.closed_at, datetime)
|
||||
|
||||
def test_close_already_closed_issue_raises_error(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueStateError) as exc_info:
|
||||
issue.close()
|
||||
|
||||
assert "Issue is already closed" in str(exc_info.value)
|
||||
assert exc_info.value.current_state == "closed"
|
||||
assert exc_info.value.attempted_state == "closed"
|
||||
|
||||
def test_reopen_closed_issue_changes_state_and_clears_closed_at(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.reopen()
|
||||
|
||||
# Assert
|
||||
assert issue.state == IssueState.OPEN
|
||||
assert issue.closed_at is None
|
||||
|
||||
def test_reopen_open_issue_raises_error(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueStateError) as exc_info:
|
||||
issue.reopen()
|
||||
|
||||
assert "Issue is not closed" in str(exc_info.value)
|
||||
|
||||
def test_add_label_to_issue(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = Label("priority:high")
|
||||
|
||||
# Act
|
||||
issue.add_label(new_label)
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 2
|
||||
assert new_label in issue.labels
|
||||
|
||||
def test_add_duplicate_label_does_not_duplicate(self):
|
||||
# Arrange
|
||||
label = Label("bug")
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[label],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.add_label(label)
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 1
|
||||
|
||||
def test_remove_label_from_issue(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug"), Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
issue.remove_label("bug")
|
||||
|
||||
# Assert
|
||||
assert len(issue.labels) == 1
|
||||
assert not any(label.name == "bug" for label in issue.labels)
|
||||
|
||||
def test_has_label_returns_correct_value(self):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug"), Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
assert issue.has_label("bug") is True
|
||||
assert issue.has_label("priority:high") is True
|
||||
assert issue.has_label("enhancement") is False
|
||||
|
||||
|
||||
class TestLabelCategories:
|
||||
"""Test LabelCategories value object."""
|
||||
|
||||
def test_label_categories_creation(self):
|
||||
# Arrange & Act
|
||||
categories = LabelCategories(
|
||||
state_labels=["status:open"],
|
||||
priority_labels=["priority:high"],
|
||||
type_labels=["bug"],
|
||||
other_labels=["custom"]
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert categories.state_labels == ["status:open"]
|
||||
assert categories.priority_labels == ["priority:high"]
|
||||
assert categories.type_labels == ["bug"]
|
||||
assert categories.other_labels == ["custom"]
|
||||
368
tests/unit/domain/issues/test_issue_services.py
Normal file
368
tests/unit/domain/issues/test_issue_services.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Unit tests for Issue domain services.
|
||||
|
||||
Tests business logic in issue services with no external dependencies.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from domain.issues.models import Issue, Label, IssueState
|
||||
from domain.issues.services import IssueStatusService, IssueValidationService
|
||||
from domain.issues.exceptions import IssueValidationError
|
||||
|
||||
|
||||
class TestIssueStatusService:
|
||||
"""Test business logic in issue status service."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return IssueStatusService()
|
||||
|
||||
def test_determine_kanban_column_for_closed_issue(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Closed Issue",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == "Done"
|
||||
|
||||
@pytest.mark.parametrize("status_label,expected_column", [
|
||||
("status:in-progress", "In Progress"),
|
||||
("status:review", "Review"),
|
||||
("status:blocked", "Blocked"),
|
||||
("status:ready", "Ready"),
|
||||
])
|
||||
def test_determine_kanban_column_based_on_status_labels(self, service, status_label, expected_column):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label(status_label)],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Blocked", "Ready", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == expected_column
|
||||
|
||||
def test_determine_kanban_column_defaults_to_todo(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="New Issue",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")], # No status label
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]}
|
||||
|
||||
# Act
|
||||
column = service.determine_kanban_column(issue, project_info)
|
||||
|
||||
# Assert
|
||||
assert column == "Todo"
|
||||
|
||||
@pytest.mark.parametrize("priority_label,expected_level", [
|
||||
("priority:low", "Low"),
|
||||
("priority:medium", "Medium"),
|
||||
("priority:high", "High"),
|
||||
("priority:critical", "Critical"),
|
||||
])
|
||||
def test_extract_priority_info_with_priority_labels(self, service, priority_label, expected_level):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label(priority_label)],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
priority_info = service.extract_priority_info(issue)
|
||||
|
||||
# Assert
|
||||
assert priority_info["level"] == expected_level
|
||||
assert priority_info["label"] == priority_label
|
||||
|
||||
def test_extract_priority_info_defaults_to_medium(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")], # No priority label
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
priority_info = service.extract_priority_info(issue)
|
||||
|
||||
# Assert
|
||||
assert priority_info["level"] == "Medium"
|
||||
assert priority_info["label"] is None
|
||||
|
||||
def test_extract_state_info_for_open_issue(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("status:in-progress")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
state_info = service.extract_state_info(issue)
|
||||
|
||||
# Assert
|
||||
assert state_info["state"] == "open"
|
||||
assert state_info["state_labels"] == ["status:in-progress"]
|
||||
assert state_info["is_closed"] is False
|
||||
assert state_info["closed_at"] is None
|
||||
|
||||
def test_extract_state_info_for_closed_issue(self, service):
|
||||
# Arrange
|
||||
closed_at = datetime.utcnow()
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=closed_at
|
||||
)
|
||||
|
||||
# Act
|
||||
state_info = service.extract_state_info(issue)
|
||||
|
||||
# Assert
|
||||
assert state_info["state"] == "closed"
|
||||
assert state_info["is_closed"] is True
|
||||
assert state_info["closed_at"] == closed_at.isoformat()
|
||||
|
||||
def test_calculate_issue_age_days(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=5)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
age_days = service.calculate_issue_age_days(issue)
|
||||
|
||||
# Assert
|
||||
assert age_days == 5
|
||||
|
||||
def test_is_stale_issue_with_old_open_issue(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=45)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is True
|
||||
|
||||
def test_is_stale_issue_with_recent_open_issue(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=15)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is False
|
||||
|
||||
def test_is_stale_issue_with_closed_issue_never_stale(self, service):
|
||||
# Arrange
|
||||
created_at = datetime.utcnow() - timedelta(days=100)
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.CLOSED,
|
||||
labels=[],
|
||||
created_at=created_at,
|
||||
updated_at=datetime.utcnow(),
|
||||
closed_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Act
|
||||
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
|
||||
|
||||
# Assert
|
||||
assert is_stale is False
|
||||
|
||||
|
||||
class TestIssueValidationService:
|
||||
"""Test business logic in issue validation service."""
|
||||
|
||||
@pytest.fixture
|
||||
def service(self):
|
||||
return IssueValidationService()
|
||||
|
||||
def test_validate_issue_creation_with_valid_data(self, service):
|
||||
# Arrange
|
||||
title = "Valid Issue Title"
|
||||
labels = ["bug", "priority:high"]
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
def test_validate_issue_creation_with_empty_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = ""
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot be empty" in str(exc_info.value)
|
||||
assert exc_info.value.field == "title"
|
||||
|
||||
def test_validate_issue_creation_with_whitespace_only_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = " "
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot be empty" in str(exc_info.value)
|
||||
|
||||
def test_validate_issue_creation_with_too_long_title_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "x" * 256 # Too long
|
||||
labels = ["bug"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue title cannot exceed 255 characters" in str(exc_info.value)
|
||||
|
||||
def test_validate_issue_creation_with_multiple_priority_labels_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "Valid Title"
|
||||
labels = ["bug", "priority:high", "priority:low"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue cannot have multiple priority labels" in str(exc_info.value)
|
||||
assert exc_info.value.field == "labels"
|
||||
|
||||
def test_validate_issue_creation_with_multiple_state_labels_raises_error(self, service):
|
||||
# Arrange
|
||||
title = "Valid Title"
|
||||
labels = ["bug", "status:open", "status:in-progress"]
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_issue_creation(title, labels)
|
||||
|
||||
assert "Issue cannot have multiple state labels" in str(exc_info.value)
|
||||
|
||||
def test_validate_title_update_with_valid_title(self, service):
|
||||
# Arrange
|
||||
new_title = "Updated Title"
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_title_update(new_title)
|
||||
|
||||
def test_validate_label_addition_to_issue_without_conflicts(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "enhancement"
|
||||
|
||||
# Act & Assert - Should not raise exception
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
def test_validate_label_addition_with_duplicate_label_raises_error(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("bug")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "bug"
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
assert "Issue already has label 'bug'" in str(exc_info.value)
|
||||
|
||||
def test_validate_label_addition_with_conflicting_priority_raises_error(self, service):
|
||||
# Arrange
|
||||
issue = Issue(
|
||||
number=1,
|
||||
title="Test",
|
||||
state=IssueState.OPEN,
|
||||
labels=[Label("priority:high")],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
new_label = "priority:low"
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(IssueValidationError) as exc_info:
|
||||
service.validate_label_addition(issue, new_label)
|
||||
|
||||
assert "Issue already has priority label" in str(exc_info.value)
|
||||
assert "Cannot add 'priority:low'" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user