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:
2025-09-26 22:15:45 +02:00
parent a7a7960ef6
commit 0606115104
20 changed files with 6024 additions and 0 deletions

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

View 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)