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)

View File

@@ -0,0 +1,607 @@
"""
Unit tests for Project domain models.
Tests pure business logic with no external dependencies.
"""
import pytest
from datetime import datetime, timedelta
from domain.projects.models import Project, Milestone, ProjectState
from domain.projects.exceptions import MilestoneError
class TestMilestone:
"""Test Milestone entity."""
def test_milestone_creation(self):
# Arrange
due_date = datetime.utcnow() + timedelta(days=30)
# Act
milestone = Milestone(
id=1,
title="Version 1.0",
description="First release",
due_date=due_date,
state="open",
open_issues=5,
closed_issues=3
)
# Assert
assert milestone.id == 1
assert milestone.title == "Version 1.0"
assert milestone.description == "First release"
assert milestone.due_date == due_date
assert milestone.state == "open"
assert milestone.open_issues == 5
assert milestone.closed_issues == 3
def test_completion_percentage_calculation(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=2,
closed_issues=8
)
# Act
percentage = milestone.completion_percentage
# Assert
assert percentage == 80.0 # 8/(2+8) * 100
def test_completion_percentage_with_no_issues(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=0,
closed_issues=0
)
# Act
percentage = milestone.completion_percentage
# Assert
assert percentage == 0.0
def test_total_issues_property(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=3,
closed_issues=7
)
# Act & Assert
assert milestone.total_issues == 10
def test_is_overdue_with_past_due_date(self):
# Arrange
past_date = datetime.utcnow() - timedelta(days=1)
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=past_date,
state="open",
open_issues=1,
closed_issues=0
)
# Act & Assert
assert milestone.is_overdue() is True
def test_is_overdue_with_future_due_date(self):
# Arrange
future_date = datetime.utcnow() + timedelta(days=1)
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=future_date,
state="open",
open_issues=1,
closed_issues=0
)
# Act & Assert
assert milestone.is_overdue() is False
def test_is_overdue_with_no_due_date(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=1,
closed_issues=0
)
# Act & Assert
assert milestone.is_overdue() is False
def test_is_overdue_with_closed_milestone(self):
# Arrange
past_date = datetime.utcnow() - timedelta(days=1)
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=past_date,
state="closed",
open_issues=0,
closed_issues=5
)
# Act & Assert
assert milestone.is_overdue() is False
def test_is_completed_with_closed_state(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="closed",
open_issues=0,
closed_issues=5
)
# Act & Assert
assert milestone.is_completed() is True
def test_is_completed_with_100_percent_completion(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=0,
closed_issues=5
)
# Act & Assert
assert milestone.is_completed() is True
def test_is_completed_with_partial_completion(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=2,
closed_issues=3
)
# Act & Assert
assert milestone.is_completed() is False
def test_add_issue_increments_open_count(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=3,
closed_issues=2
)
# Act
milestone.add_issue()
# Assert
assert milestone.open_issues == 4
assert milestone.closed_issues == 2
def test_close_issue_moves_from_open_to_closed(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=3,
closed_issues=2
)
# Act
milestone.close_issue()
# Assert
assert milestone.open_issues == 2
assert milestone.closed_issues == 3
def test_close_issue_with_no_open_issues_raises_error(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=0,
closed_issues=5
)
# Act & Assert
with pytest.raises(MilestoneError) as exc_info:
milestone.close_issue()
assert "no open issues" in str(exc_info.value)
assert exc_info.value.milestone_id == 1
def test_reopen_issue_moves_from_closed_to_open(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=2,
closed_issues=3
)
# Act
milestone.reopen_issue()
# Assert
assert milestone.open_issues == 3
assert milestone.closed_issues == 2
def test_reopen_issue_with_no_closed_issues_raises_error(self):
# Arrange
milestone = Milestone(
id=1,
title="Test",
description=None,
due_date=None,
state="open",
open_issues=5,
closed_issues=0
)
# Act & Assert
with pytest.raises(MilestoneError) as exc_info:
milestone.reopen_issue()
assert "no closed issues" in str(exc_info.value)
assert exc_info.value.milestone_id == 1
class TestProject:
"""Test Project aggregate root."""
def test_project_creation(self):
# Arrange
created_at = datetime.utcnow()
updated_at = datetime.utcnow()
milestones = [
Milestone(1, "M1", None, None, "open", 2, 1),
Milestone(2, "M2", None, None, "closed", 0, 3)
]
# Act
project = Project(
name="Test Project",
description="A test project",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=["Todo", "In Progress", "Done"],
created_at=created_at,
updated_at=updated_at
)
# Assert
assert project.name == "Test Project"
assert project.description == "A test project"
assert project.state == ProjectState.ACTIVE
assert len(project.milestones) == 2
assert project.kanban_columns == ["Todo", "In Progress", "Done"]
def test_get_active_milestones(self):
# Arrange
milestones = [
Milestone(1, "M1", None, None, "open", 2, 1),
Milestone(2, "M2", None, None, "closed", 0, 3),
Milestone(3, "M3", None, None, "open", 1, 0)
]
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
active_milestones = project.get_active_milestones()
# Assert
assert len(active_milestones) == 2
assert all(m.state == "open" for m in active_milestones)
def test_get_completed_milestones(self):
# Arrange
milestones = [
Milestone(1, "M1", None, None, "open", 2, 1), # Not completed
Milestone(2, "M2", None, None, "closed", 0, 3), # Completed (closed)
Milestone(3, "M3", None, None, "open", 0, 5) # Completed (100%)
]
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
completed_milestones = project.get_completed_milestones()
# Assert
assert len(completed_milestones) == 2
def test_get_overdue_milestones(self):
# Arrange
past_date = datetime.utcnow() - timedelta(days=1)
future_date = datetime.utcnow() + timedelta(days=1)
milestones = [
Milestone(1, "M1", None, past_date, "open", 2, 1), # Overdue
Milestone(2, "M2", None, future_date, "open", 1, 0), # Not overdue
Milestone(3, "M3", None, None, "open", 1, 0) # No due date
]
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
overdue_milestones = project.get_overdue_milestones()
# Assert
assert len(overdue_milestones) == 1
assert overdue_milestones[0].id == 1
def test_calculate_overall_progress(self):
# Arrange
milestones = [
Milestone(1, "M1", None, None, "open", 1, 4), # 80% complete
Milestone(2, "M2", None, None, "open", 3, 2) # 40% complete
]
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
progress = project.calculate_overall_progress()
# Assert
assert progress == 60.0 # (80 + 40) / 2
def test_calculate_overall_progress_with_no_milestones(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
progress = project.calculate_overall_progress()
# Assert
assert progress == 0.0
def test_get_total_issues(self):
# Arrange
milestones = [
Milestone(1, "M1", None, None, "open", 2, 3), # 5 total
Milestone(2, "M2", None, None, "open", 1, 4) # 5 total
]
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=milestones,
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act & Assert
assert project.get_total_issues() == 10
assert project.get_total_open_issues() == 3
assert project.get_total_closed_issues() == 7
def test_archive_project(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
project.archive()
# Assert
assert project.state == ProjectState.ARCHIVED
assert project.archived_at is not None
def test_activate_project(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ARCHIVED,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
archived_at=datetime.utcnow()
)
# Act
project.activate()
# Assert
assert project.state == ProjectState.ACTIVE
assert project.archived_at is None
def test_add_milestone(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
milestone = Milestone(1, "New Milestone", None, None, "open", 0, 0)
# Act
project.add_milestone(milestone)
# Assert
assert len(project.milestones) == 1
assert project.milestones[0] == milestone
def test_add_duplicate_milestone_raises_error(self):
# Arrange
milestone1 = Milestone(1, "Milestone 1", None, None, "open", 0, 0)
milestone2 = Milestone(1, "Milestone 2", None, None, "open", 0, 0) # Same ID
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[milestone1],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act & Assert
with pytest.raises(ValueError, match="Milestone with ID 1 already exists"):
project.add_milestone(milestone2)
def test_remove_milestone(self):
# Arrange
milestone = Milestone(1, "Milestone", None, None, "open", 0, 0)
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[milestone],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
project.remove_milestone(1)
# Assert
assert len(project.milestones) == 0
def test_remove_nonexistent_milestone_raises_error(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act & Assert
with pytest.raises(ValueError, match="Milestone with ID 999 not found"):
project.remove_milestone(999)
def test_get_milestone(self):
# Arrange
milestone = Milestone(1, "Milestone", None, None, "open", 0, 0)
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[milestone],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
found_milestone = project.get_milestone(1)
# Assert
assert found_milestone == milestone
def test_get_nonexistent_milestone_returns_none(self):
# Arrange
project = Project(
name="Test",
description="",
state=ProjectState.ACTIVE,
milestones=[],
kanban_columns=[],
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Act
found_milestone = project.get_milestone(999)
# Assert
assert found_milestone is None